diff --git a/samples/CodeBeam.UltimateAuth.Sample.Seed/UserSeedContributor.cs b/samples/CodeBeam.UltimateAuth.Sample.Seed/UserSeedContributor.cs index 5d285880..3dde6696 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Seed/UserSeedContributor.cs +++ b/samples/CodeBeam.UltimateAuth.Sample.Seed/UserSeedContributor.cs @@ -59,11 +59,11 @@ await lifecycleStore.AddAsync( ct); } - var profileKey = new UserProfileKey(tenant, userKey); + var profileKey = new UserProfileKey(tenant, userKey, ProfileKey.Default); if (!await profileStore.ExistsAsync(profileKey, ct)) { await profileStore.AddAsync( - UserProfile.Create(Guid.NewGuid(), tenant, userKey, now, displayName: displayName), + UserProfile.Create(Guid.NewGuid(), tenant, userKey, ProfileKey.Default, now, displayName: displayName), ct); } diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs index 9108573e..9784acb8 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs @@ -38,6 +38,7 @@ o.Login.MaxFailedAttempts = 2; o.Login.LockoutDuration = TimeSpan.FromSeconds(10); o.Identifiers.AllowMultipleUsernames = true; + o.UserProfile.EnableMultiProfile = true; }) .AddUltimateAuthInMemory() .AddUAuthHub(o => o.AllowedClientOrigins.Add("https://localhost:6130")); // Client sample's URL diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UsersDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UsersDialog.razor.cs index e6807ab6..3f963991 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UsersDialog.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UsersDialog.razor.cs @@ -144,7 +144,7 @@ Are you sure you want to delete {user.DisplayName ?? user.UserName ?? user.Pr Mode = DeleteMode.Soft }; - var result = await UAuthClient.Users.DeleteUserAsync(UserKey.Parse(user.UserKey, null), req); + var result = await UAuthClient.Users.DeleteUserAsync(user.UserKey, req); if (result.IsSuccess) { diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260327184128_InitUltimateAuth.Designer.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260406192328_InitUltimateAuth.Designer.cs similarity index 98% rename from samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260327184128_InitUltimateAuth.Designer.cs rename to samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260406192328_InitUltimateAuth.Designer.cs index 079580bb..1302213b 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260327184128_InitUltimateAuth.Designer.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260406192328_InitUltimateAuth.Designer.cs @@ -11,7 +11,7 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Migrations { [DbContext(typeof(UAuthDbContext))] - [Migration("20260327184128_InitUltimateAuth")] + [Migration("20260406192328_InitUltimateAuth")] partial class InitUltimateAuth { /// @@ -658,6 +658,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("Metadata") .HasColumnType("TEXT"); + b.Property("ProfileKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + b.Property("Tenant") .IsRequired() .HasMaxLength(128) @@ -680,7 +685,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("Tenant", "UserKey"); + b.HasIndex("Tenant", "UserKey", "ProfileKey") + .IsUnique(); b.ToTable("UAuth_UserProfiles", (string)null); }); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260327184128_InitUltimateAuth.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260406192328_InitUltimateAuth.cs similarity index 99% rename from samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260327184128_InitUltimateAuth.cs rename to samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260406192328_InitUltimateAuth.cs index 9f373138..bd4101fd 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260327184128_InitUltimateAuth.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260406192328_InitUltimateAuth.cs @@ -182,6 +182,7 @@ protected override void Up(MigrationBuilder migrationBuilder) Id = table.Column(type: "TEXT", nullable: false), Tenant = table.Column(type: "TEXT", maxLength: 128, nullable: false), UserKey = table.Column(type: "TEXT", maxLength: 128, nullable: false), + ProfileKey = table.Column(type: "TEXT", maxLength: 64, nullable: false), FirstName = table.Column(type: "TEXT", nullable: true), LastName = table.Column(type: "TEXT", nullable: true), DisplayName = table.Column(type: "TEXT", nullable: true), @@ -498,9 +499,10 @@ protected override void Up(MigrationBuilder migrationBuilder) unique: true); migrationBuilder.CreateIndex( - name: "IX_UAuth_UserProfiles_Tenant_UserKey", + name: "IX_UAuth_UserProfiles_Tenant_UserKey_ProfileKey", table: "UAuth_UserProfiles", - columns: new[] { "Tenant", "UserKey" }); + columns: new[] { "Tenant", "UserKey", "ProfileKey" }, + unique: true); migrationBuilder.CreateIndex( name: "IX_UAuth_UserRoles_Tenant_RoleId", diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/UAuthDbContextModelSnapshot.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/UAuthDbContextModelSnapshot.cs index 211ef12e..af13077f 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/UAuthDbContextModelSnapshot.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/UAuthDbContextModelSnapshot.cs @@ -655,6 +655,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Metadata") .HasColumnType("TEXT"); + b.Property("ProfileKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + b.Property("Tenant") .IsRequired() .HasMaxLength(128) @@ -677,7 +682,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("Tenant", "UserKey"); + b.HasIndex("Tenant", "UserKey", "ProfileKey") + .IsUnique(); b.ToTable("UAuth_UserProfiles", (string)null); }); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db index 4e86411b..b911a93d 100644 Binary files a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db and b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db differ diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm deleted file mode 100644 index 9c25688e..00000000 Binary files a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm and /dev/null differ diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal deleted file mode 100644 index 42498f65..00000000 Binary files a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal and /dev/null differ diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UsersDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UsersDialog.razor.cs index 3168085d..5d184fe7 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UsersDialog.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UsersDialog.razor.cs @@ -144,7 +144,7 @@ Are you sure you want to delete {user.DisplayName ?? user.UserName ?? user.Pr Mode = DeleteMode.Soft }; - var result = await UAuthClient.Users.DeleteUserAsync(UserKey.Parse(user.UserKey, null), req); + var result = await UAuthClient.Users.DeleteUserAsync(user.UserKey, req); if (result.IsSuccess) { diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IUAuthPasswordHasher.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IUAuthPasswordHasher.cs index 039a8216..82946e79 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IUAuthPasswordHasher.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IUAuthPasswordHasher.cs @@ -7,6 +7,7 @@ /// public interface IUAuthPasswordHasher { - string Hash(string password); - bool Verify(string hash, string secret); + PasswordHash Hash(string password); + bool Verify(PasswordHash hash, string secret); + bool NeedsRehash(PasswordHash hash); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverter.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/User/IUserIdConverter.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverter.cs rename to src/CodeBeam.UltimateAuth.Core/Abstractions/User/IUserIdConverter.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverterResolver.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/User/IUserIdConverterResolver.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverterResolver.cs rename to src/CodeBeam.UltimateAuth.Core/Abstractions/User/IUserIdConverterResolver.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/User/IUserIdFactory.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdFactory.cs rename to src/CodeBeam.UltimateAuth.Core/Abstractions/User/IUserIdFactory.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs index 32da871e..64f2be08 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs @@ -1,5 +1,4 @@ using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Core.Contracts; diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs index f5089e7a..9ffe9ab8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs @@ -1,5 +1,4 @@ using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Core.Contracts; diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs index b951e1ec..7c057ff2 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs @@ -15,8 +15,8 @@ public sealed record PkceCompleteRequest public required string Secret { get; init; } [JsonPropertyName("return_url")] - public string ReturnUrl { get; init; } + public string? ReturnUrl { get; init; } [JsonPropertyName("hub_session_id")] - public string HubSessionId { get; init; } + public string? HubSessionId { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshStrategy.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshStrategy.cs deleted file mode 100644 index 731248f6..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshStrategy.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts; - -public enum RefreshStrategy -{ - NotSupported = 0, - SessionOnly = 10, // PureOpaque - TokenOnly = 20, // PureJwt - TokenWithSessionCheck = 30, // SemiHybrid - SessionAndToken = 40 // Hybrid -} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRefreshStatus.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/SessionRefreshStatus.cs similarity index 70% rename from src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRefreshStatus.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/SessionRefreshStatus.cs index 16f23a04..41ed8a4e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRefreshStatus.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/SessionRefreshStatus.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Core.Domain; +namespace CodeBeam.UltimateAuth.Core.Contracts; public enum SessionRefreshStatus { diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/IdentityInfo.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/IdentityInfo.cs index 4f008972..c7488e64 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/IdentityInfo.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/IdentityInfo.cs @@ -7,5 +7,4 @@ public sealed class IdentityInfo public string? UserKey { get; set; } public DateTimeOffset? AuthenticatedAt { get; set; } - } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionStoreContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionStoreContext.cs deleted file mode 100644 index 3bcda73b..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionStoreContext.cs +++ /dev/null @@ -1,43 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Core.Contracts; - -/// -/// Context information required by the session store when -/// creating or rotating sessions. -/// -public sealed class SessionStoreContext -{ - /// - /// The authenticated user identifier. - /// - public required UserKey UserKey { get; init; } - - /// - /// The tenant identifier, if multi-tenancy is enabled. - /// - public TenantKey Tenant { get; init; } - - /// - /// Optional chain identifier. - /// If null, a new chain should be created. - /// - public SessionChainId? ChainId { get; init; } - - /// - /// Indicates whether the session is metadata-only - /// (used in SemiHybrid mode). - /// - public bool IsMetadataOnly { get; init; } - - /// - /// The UTC timestamp when the session was issued. - /// - public DateTimeOffset IssuedAt { get; init; } - - /// - /// Optional device or client identifier. - /// - public required DeviceContext Device { get; init; } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AccessToken.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AccessToken.cs index 459c8d0d..8c7169c3 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AccessToken.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AccessToken.cs @@ -15,7 +15,7 @@ public sealed class AccessToken /// Token type: "jwt" or "opaque". /// Used for diagnostics and middleware behavior. /// - public TokenType Type { get; init; } + public TokenFormat Format { get; init; } /// /// Expiration time of the token. diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenFormat.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenFormat.cs index a50157ce..b748de6c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenFormat.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenFormat.cs @@ -1,9 +1,8 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; -// TODO: It's same as TokenType -// It's not primary token kind, it's about transport format. public enum TokenFormat { Opaque = 0, - Jwt = 10 + Jwt = 10, + Unknown = 100 } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenType.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenType.cs deleted file mode 100644 index da231a01..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenType.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts; - -public enum TokenType -{ - Opaque = 0, - Jwt = 10, - Unknown = 100 -} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs index f0247ddf..372fd4fd 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs @@ -7,7 +7,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record TokenValidationResult { public bool IsValid { get; init; } - public TokenType Type { get; init; } + public TokenFormat Format { get; init; } public TenantKey? Tenant { get; init; } public TUserId? UserId { get; init; } public AuthSessionId? SessionId { get; init; } @@ -17,7 +17,7 @@ public sealed record TokenValidationResult private TokenValidationResult( bool isValid, - TokenType type, + TokenFormat format, TenantKey? tenant, TUserId? userId, AuthSessionId? sessionId, @@ -27,6 +27,7 @@ private TokenValidationResult( ) { IsValid = isValid; + Format = format; Tenant = tenant; UserId = userId; SessionId = sessionId; @@ -36,7 +37,7 @@ private TokenValidationResult( } public static TokenValidationResult Valid( - TokenType type, + TokenFormat format, TenantKey tenant, TUserId userId, AuthSessionId? sessionId, @@ -44,7 +45,7 @@ public static TokenValidationResult Valid( DateTimeOffset? expiresAt) => new( isValid: true, - type, + format, tenant, userId, sessionId, @@ -53,10 +54,10 @@ public static TokenValidationResult Valid( expiresAt ); - public static TokenValidationResult Invalid(TokenType type, TokenInvalidReason reason) + public static TokenValidationResult Invalid(TokenFormat format, TokenInvalidReason reason) => new( isValid: false, - type: type, + format: format, tenant: null, userId: default, sessionId: null, diff --git a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs index 58fc735b..337cc47c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs @@ -68,8 +68,12 @@ public static class UserProfiles public const string GetSelf = "users.profile.get.self"; public const string UpdateSelf = "users.profile.update.self"; + public const string CreateSelf = "users.profile.add.self"; + public const string CreateAdmin = "users.profile.add.admin"; public const string GetAdmin = "users.profile.get.admin"; public const string UpdateAdmin = "users.profile.update.admin"; + public const string DeleteSelf = "users.profile.delete.self"; + public const string DeleteAdmin = "users.profile.delete.admin"; } public static class UserIdentifiers diff --git a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs index c6761ad6..37881b9d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs +++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs @@ -31,6 +31,7 @@ public static class Form public const string ReturnUrl = "return_url"; public const string Device = "__uauth_device"; public const string ClientProfile = "__uauth_client_profile"; + public const string FormCacheKey = "__uauth_form"; } public static class Query diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Auth/AuthArtifactType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Auth/AuthArtifactType.cs index 85157ad7..a7d9bef7 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Auth/AuthArtifactType.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Auth/AuthArtifactType.cs @@ -2,13 +2,13 @@ public enum AuthArtifactType { - PkceAuthorizationCode, - HubFlow, - LoginPreview, - HubLogin, - MfaChallenge, - PasswordReset, - MagicLink, - OAuthState, + PkceAuthorizationCode = 0, + HubFlow = 10, + LoginPreview = 20, + HubLogin = 30, + MfaChallenge = 40, + PasswordReset = 50, + MagicLink = 60, + OAuthState = 100, Custom = 1000 } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Auth/AuthFlowType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Auth/AuthFlowType.cs new file mode 100644 index 00000000..b1202880 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Auth/AuthFlowType.cs @@ -0,0 +1,29 @@ +namespace CodeBeam.UltimateAuth.Core.Domain; + +public enum AuthFlowType +{ + Login = 0, + Reauthentication = 10, + Logout = 20, + + RefreshSession = 100, + ValidateSession = 110, + QuerySession = 120, + RevokeSession = 130, + + IssueToken = 200, + RefreshToken = 210, + IntrospectToken = 220, + RevokeToken = 230, + + UserInfo = 300, + PermissionQuery = 310, + + UserManagement = 400, + UserProfileManagement = 410, + UserIdentifierManagement = 420, + CredentialManagement = 430, + AuthorizationManagement = 440, + + ApiAccess = 500 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs deleted file mode 100644 index 905d54df..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Domain; - -public enum AuthFlowType -{ - Login, - Reauthentication, - - Logout, - RefreshSession, - ValidateSession, - - IssueToken, - RefreshToken, - IntrospectToken, - RevokeToken, - - QuerySession, - RevokeSession, - - UserInfo, - PermissionQuery, - - UserManagement, - UserProfileManagement, - UserIdentifierManagement, - CredentialManagement, - AuthorizationManagement, - - ApiAccess -} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubErrorCode.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubErrorCode.cs index c185ab21..ca09b19b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubErrorCode.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubErrorCode.cs @@ -3,8 +3,8 @@ public enum HubErrorCode { None = 0, - InvalidCredentials, - LockedOut, - RequiresMfa, - Unknown + InvalidCredentials = 10, + LockedOut = 20, + RequiresMfa = 30, + Unknown = 100 } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowType.cs index 3d3980c7..fe4ea93c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowType.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowType.cs @@ -4,10 +4,10 @@ public enum HubFlowType { None = 0, - Login = 1, - Mfa = 2, - Reauthentication = 3, - Consent = 4, + Login = 10, + Mfa = 20, + Reauthentication = 30, + Consent = 40, Custom = 1000 } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/HubLoginArtifact.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/HubLoginArtifact.cs deleted file mode 100644 index c6ceb415..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/HubLoginArtifact.cs +++ /dev/null @@ -1,17 +0,0 @@ -//namespace CodeBeam.UltimateAuth.Core.Domain; - -//public sealed class HubLoginArtifact : AuthArtifact -//{ -// public string AuthorizationCode { get; } -// public string CodeVerifier { get; } - -// public HubLoginArtifact( -// string authorizationCode, -// string codeVerifier, -// DateTimeOffset expiresAt) -// : base(AuthArtifactType.HubLogin, expiresAt) -// { -// AuthorizationCode = authorizationCode; -// CodeVerifier = codeVerifier; -// } -//} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/AuthFailureReason.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/AuthFailureReason.cs index b0b148ed..23510367 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/AuthFailureReason.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/AuthFailureReason.cs @@ -2,13 +2,13 @@ public enum AuthFailureReason { - InvalidCredentials, - LockedOut, - RequiresMfa, - SessionExpired, - SessionRevoked, - TenantDisabled, - Unauthorized, - ReauthenticationRequired, - Unknown + InvalidCredentials = 0, + LockedOut = 10, + RequiresMfa = 20, + ReauthenticationRequired = 30, + Unauthorized = 40, + SessionExpired = 100, + SessionRevoked = 110, + TenantDisabled = 120, + Unknown = 1000 } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/GrantKind.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/GrantKind.cs index 601b18f8..162dac79 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/GrantKind.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/GrantKind.cs @@ -2,7 +2,7 @@ public enum GrantKind { - Session, - AccessToken, - RefreshToken + Session = 0, + AccessToken = 10, + RefreshToken = 20 } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryGrantKind.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryGrantKind.cs deleted file mode 100644 index 9e12f09d..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryGrantKind.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Domain; - -public enum PrimaryGrantKind -{ - Stateful, - Stateless -} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryTokenKind.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryTokenKind.cs similarity index 58% rename from src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryTokenKind.cs rename to src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryTokenKind.cs index 06cd52af..7b3ae60e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryTokenKind.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryTokenKind.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts; +namespace CodeBeam.UltimateAuth.Core.Domain; public enum PrimaryTokenKind { diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ReauthBehavior.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ReauthBehavior.cs index 44262fe4..077f8f96 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ReauthBehavior.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ReauthBehavior.cs @@ -2,7 +2,7 @@ public enum ReauthBehavior { - Redirect, - None, - RaiseEvent + Redirect = 0, + None = 10, + RaiseEvent = 20 } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityScope.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityScope.cs index cd0c6c31..052c9a64 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityScope.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityScope.cs @@ -3,5 +3,5 @@ public enum AuthenticationSecurityScope { Account = 0, - Factor = 1 + Factor = 10 } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Security/CredentialType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/CredentialType.cs index 35226f1b..1972b9b4 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Security/CredentialType.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/CredentialType.cs @@ -12,12 +12,12 @@ public enum CredentialType Totp = 30, // Modern - Passkey = 40, + Passkey = 100, // Machine / system - Certificate = 50, - ApiKey = 60, + Certificate = 200, + ApiKey = 210, // External / Federated // TODO: Add Microsoft, Google, GitHub etc. - External = 70 + External = 1000 } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Security/PasswordHash.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/PasswordHash.cs new file mode 100644 index 00000000..a95107d6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/PasswordHash.cs @@ -0,0 +1,58 @@ +using CodeBeam.UltimateAuth.Core.Errors; + +namespace CodeBeam.UltimateAuth.Core; +public readonly record struct PasswordHash : IParsable +{ + public string Algorithm { get; } + public string Hash { get; } + + private PasswordHash(string algorithm, string hash) + { + Algorithm = algorithm; + Hash = hash; + } + + public static PasswordHash Create(string algorithm, string hash) + { + if (string.IsNullOrWhiteSpace(algorithm)) + throw new UAuthValidationException("hash_algorithm_required"); + + if (string.IsNullOrWhiteSpace(hash)) + throw new UAuthValidationException("hash_required"); + + return new PasswordHash(algorithm, hash); + } + + public static PasswordHash Parse(string s, IFormatProvider? provider) + { + if (!TryParse(s, provider, out var result)) + throw new FormatException("Invalid PasswordHash format."); + + return result; + } + + public static bool TryParse(string? s, IFormatProvider? provider, out PasswordHash result) + { + if (string.IsNullOrWhiteSpace(s)) + { + result = default; + return false; + } + + var parts = s.Split('$', 2); + + if (parts.Length != 2) + { + // backward compatibility + result = new PasswordHash("legacy", s); + return true; + } + + result = new PasswordHash(parts[0], parts[1]); + return true; + } + + public override string ToString() => $"{Algorithm}${Hash}"; + + public static implicit operator string(PasswordHash value) => value.ToString(); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Security/PasswordHashJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/PasswordHashJsonConverter.cs new file mode 100644 index 00000000..f501a39f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/PasswordHashJsonConverter.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core; + +public sealed class PasswordHashJsonConverter : JsonConverter +{ + public override PasswordHash Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + throw new JsonException("PasswordHash must be a string."); + + var value = reader.GetString(); + + if (!PasswordHash.TryParse(value, null, out var result)) + throw new JsonException($"Invalid PasswordHash: '{value}'"); + + return result; + } + + public override void Write(Utf8JsonWriter writer, PasswordHash value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs index 27229e1f..da64faf0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs @@ -2,9 +2,9 @@ public enum RefreshOutcome { - Success, // minimal transport - NoOp, - Touched, - Rotated, - ReauthRequired + Success = 0, // minimal transport + NoOp = 10, + Touched = 20, + Rotated = 30, + ReauthRequired = 100 } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthConflictException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthConflictException.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Errors/UAuthConflictException.cs rename to src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthConflictException.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthForbiddenException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthForbiddenException.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Errors/UAuthForbiddenException.cs rename to src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthForbiddenException.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthUnauthorizedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthUnauthorizedException.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Errors/UAuthUnauthorizedException.cs rename to src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthUnauthorizedException.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthValidationException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthValidationException.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Errors/UAuthValidationException.cs rename to src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthValidationException.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Events/UAuthEvents.cs b/src/CodeBeam.UltimateAuth.Core/Events/UAuthEvents.cs index 9162c9fb..8a17a5d8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/UAuthEvents.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/UAuthEvents.cs @@ -1,6 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Options; - -namespace CodeBeam.UltimateAuth.Core.Events; +namespace CodeBeam.UltimateAuth.Core.Events; /// /// Provides an optional, application-wide event hook system for UltimateAuth. diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Base64Url.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Base64Url.cs index 14b2de0d..9cdfc1f5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Base64Url.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Base64Url.cs @@ -39,5 +39,4 @@ public static byte[] Decode(string input) return Convert.FromBase64String(padded); } - } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/DeviceContextJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/DeviceContextJsonConverter.cs index e0e8b013..03c27f1b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/DeviceContextJsonConverter.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/DeviceContextJsonConverter.cs @@ -14,7 +14,6 @@ public override DeviceContext Read(ref Utf8JsonReader reader, Type typeToConvert using var doc = JsonDocument.ParseValue(ref reader); var root = doc.RootElement; - // DeviceId DeviceId? deviceId = null; if (root.TryGetProperty("deviceId", out var deviceIdProp)) { diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UAuthUserIdConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UAuthUserIdConverter.cs index 9949db9b..152cf026 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UAuthUserIdConverter.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UAuthUserIdConverter.cs @@ -1,6 +1,5 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Errors; using System.Globalization; using System.Text; using System.Text.Json; @@ -107,5 +106,4 @@ public bool TryFromBytes(byte[] binary, out TUserId id) return false; } } - } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs index 0e0ab036..f46ced1f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs @@ -10,7 +10,7 @@ public static SessionValidationResult ToDomain(SessionValidationInfo dto) { var state = (SessionState)dto.State; - if (!dto.IsValid || dto.Snapshot.Identity is null) + if (!dto.IsValid || dto.Snapshot?.Identity is null) { return SessionValidationResult.Invalid(state); } diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/CompositeTenantResolver.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/CompositeTenantResolver.cs index ffc9040e..035b90b9 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/CompositeTenantResolver.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/CompositeTenantResolver.cs @@ -32,5 +32,4 @@ public CompositeTenantResolver(IEnumerable resolvers) return null; } - } diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/HeaderTenantResolver.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/HeaderTenantResolver.cs index 512ef583..3782dd31 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/HeaderTenantResolver.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/HeaderTenantResolver.cs @@ -33,5 +33,4 @@ public HeaderTenantResolver(string headerName) return Task.FromResult(null); } - } diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantContext.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantExecutionContext.cs similarity index 62% rename from src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantContext.cs rename to src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantExecutionContext.cs index 17e51cbc..262fa6e5 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantExecutionContext.cs @@ -1,11 +1,11 @@ namespace CodeBeam.UltimateAuth.Core.MultiTenancy; -public sealed class TenantContext +public sealed class TenantExecutionContext { public TenantKey Tenant { get; } public bool IsGlobal { get; } - public TenantContext(TenantKey tenant, bool isGlobal = false) + public TenantExecutionContext(TenantKey tenant, bool isGlobal = false) { Tenant = tenant; IsGlobal = isGlobal; diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/UAuthTenantContext.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/UAuthTenantContext.cs index 9f73a2c9..11907b6e 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/UAuthTenantContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/UAuthTenantContext.cs @@ -7,9 +7,9 @@ public sealed class UAuthTenantContext { public TenantKey Tenant { get; } - private UAuthTenantContext(TenantKey tenant) + private UAuthTenantContext(TenantKey tenant, bool allowUnresolved = false) { - if (tenant.IsUnresolved) + if (!allowUnresolved && tenant.IsUnresolved) throw new InvalidOperationException("Runtime tenant context cannot be unresolved."); Tenant = tenant; @@ -20,6 +20,6 @@ private UAuthTenantContext(TenantKey tenant) public static UAuthTenantContext SingleTenant() => new(TenantKey.Single); public static UAuthTenantContext System() => new(TenantKey.System); - public static UAuthTenantContext Unresolved() => new(TenantKey.Unresolved); + public static UAuthTenantContext Unresolved() => new(TenantKey.Unresolved, allowUnresolved: true); public static UAuthTenantContext Resolved(TenantKey tenant) => new(tenant); } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/HeaderTokenFormat.cs b/src/CodeBeam.UltimateAuth.Core/Options/HeaderTokenFormat.cs index 826703c8..85b9c8ce 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/HeaderTokenFormat.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/HeaderTokenFormat.cs @@ -2,6 +2,6 @@ public enum HeaderTokenFormat { - Bearer, - Raw + Bearer = 0, + Raw = 10 } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs index 33881151..9d774c7d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs @@ -38,7 +38,7 @@ public ValidateOptionsResult Validate(string? name, UAuthTokenOptions options) errors.Add("Token.OpaqueIdBytes must be at least 16 bytes (128-bit entropy)."); if (options.OpaqueIdBytes > 128) - errors.Add("Token.OpaqueIdBytes must not exceed 64 bytes."); + errors.Add("Token.OpaqueIdBytes must not exceed 128 bytes."); } return errors.Count == 0 diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs index d4bfbfca..4c4a1911 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs @@ -5,5 +5,5 @@ namespace CodeBeam.UltimateAuth.Server.Abstractions; public interface IPrimaryCredentialResolver { - PrimaryGrantKind Resolve(HttpContext context); + PrimaryTokenKind Resolve(HttpContext context); } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs index 76e977f3..cf182877 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Server.Auth { @@ -23,7 +24,7 @@ public AuthStateSnapshotFactory(IPrimaryUserIdentifierProvider identifier, IUser return null; var identifiers = await _identifier.GetAsync(validation.Tenant, validation.UserKey.Value, ct); - var profile = await _profile.GetAsync(validation.Tenant, validation.UserKey.Value, ct); + var profile = await _profile.GetAsync(validation.Tenant, validation.UserKey.Value, ProfileKey.Default, ct); var lifecycle = await _lifecycle.GetAsync(validation.Tenant, validation.UserKey.Value, ct); var identity = new AuthIdentitySnapshot diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs b/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs index 92fe58c1..671f608e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs @@ -21,12 +21,6 @@ public async Task ReadAsync(HttpContext context) return formProfile; } - //if (context.Request.HasFormContentType && context.Request.Form.TryGetValue(UAuthConstants.Form.ClientProfile, out var formValue) && - // TryParse(formValue, out var formProfile)) - //{ - // return formProfile; - //} - return UAuthClientProfile.NotSpecified; } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveUAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveUAuthServerOptions.cs index f17a63a6..5ddb0666 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveUAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveUAuthServerOptions.cs @@ -11,6 +11,4 @@ public sealed class EffectiveUAuthServerOptions /// Cloned, per-request server options /// public UAuthServerOptions Options { get; init; } = default!; - - public UAuthResponseOptions AuthResponse => Options.AuthResponse; } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/IPrimaryTokenResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/IPrimaryTokenResolver.cs index b53d9ef8..ba50be05 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/IPrimaryTokenResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/IPrimaryTokenResolver.cs @@ -1,5 +1,5 @@ using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Server.Auth; diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/PrimaryTokenResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/PrimaryTokenResolver.cs index 8e5baa88..5daee211 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/PrimaryTokenResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/PrimaryTokenResolver.cs @@ -1,5 +1,5 @@ using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Server.Auth; diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs index e2b4ed54..27b8e3ca 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs @@ -5,7 +5,7 @@ namespace CodeBeam.UltimateAuth.Server.Authentication; public static class UAuthAuthenticationExtensions { - public static AuthenticationBuilder AddUAuthCookies(this AuthenticationBuilder builder, Action? configure = null) + public static AuthenticationBuilder AddUAuthScheme(this AuthenticationBuilder builder, Action? configure = null) { return builder.AddScheme(UAuthConstants.SchemeDefaults.GlobalScheme, options => diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs index 59de49b3..f64ebecd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs @@ -10,12 +10,10 @@ namespace CodeBeam.UltimateAuth.Server.Security; internal sealed class AuthenticationSecurityManager : IAuthenticationSecurityManager { private readonly IAuthenticationSecurityStateStoreFactory _storeFactory; - private readonly UAuthServerOptions _options; - public AuthenticationSecurityManager(IAuthenticationSecurityStateStoreFactory storeFactory, IOptions options) + public AuthenticationSecurityManager(IAuthenticationSecurityStateStoreFactory storeFactory) { _storeFactory = storeFactory; - _options = options.Value; } public async Task GetOrCreateAccountAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) diff --git a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthAuthorizationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthAuthorizationHandler.cs index 3df4852a..9adc5db6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthAuthorizationHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthAuthorizationHandler.cs @@ -1,8 +1,4 @@ -using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Errors; -using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Server.Infrastructure; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; diff --git a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthPolicyProvider.cs b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthPolicyProvider.cs index 147b87dc..45491c25 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthPolicyProvider.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthPolicyProvider.cs @@ -12,13 +12,13 @@ public UAuthPolicyProvider(IOptions options) _fallback = new DefaultAuthorizationPolicyProvider(options); } - public Task GetPolicyAsync(string policyName) + public Task GetPolicyAsync(string policyName) { var policy = new AuthorizationPolicyBuilder() .AddRequirements(new UAuthActionRequirement(policyName)) .Build(); - return Task.FromResult(policy); + return Task.FromResult(policy); } public Task GetDefaultPolicyAsync() diff --git a/src/CodeBeam.UltimateAuth.Server/Composition/Extensions/AddUltimateAuthServerExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Composition/Extensions/AddUltimateAuthServerExtensions.cs deleted file mode 100644 index 91617a35..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Composition/Extensions/AddUltimateAuthServerExtensions.cs +++ /dev/null @@ -1,20 +0,0 @@ -//using CodeBeam.UltimateAuth.Core.Extensions; -//using CodeBeam.UltimateAuth.Server.Extensions; -//using CodeBeam.UltimateAuth.Server.Options; -//using Microsoft.Extensions.Configuration; -//using Microsoft.Extensions.DependencyInjection; - -//namespace CodeBeam.UltimateAuth.Server.Composition.Extensions; - -//public static class AddUltimateAuthServerExtensions -//{ -// public static UltimateAuthServerBuilder AddUltimateAuthServer(this IServiceCollection services, IConfiguration configuration) -// { -// services.AddUltimateAuth(configuration); // Core -// services.AddUAuthServerInfrastructure(); // issuer, flow, endpoints - -// services.Configure(configuration.GetSection("UltimateAuth:Server")); - -// return new UltimateAuthServerBuilder(services); -// } -//} diff --git a/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilder.cs deleted file mode 100644 index 1a119390..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilder.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace CodeBeam.UltimateAuth.Server.Composition; - -public sealed class UltimateAuthServerBuilder -{ - internal UltimateAuthServerBuilder(IServiceCollection services) - { - Services = services; - } - - public IServiceCollection Services { get; } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs b/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs deleted file mode 100644 index 8370bce1..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs +++ /dev/null @@ -1,23 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using Microsoft.Extensions.DependencyInjection; - -namespace CodeBeam.UltimateAuth.Server.Composition; - -public static class UltimateAuthServerBuilderValidationExtensions -{ - public static IServiceCollection Build(this UltimateAuthServerBuilder builder) - { - var services = builder.Services; - - if (!services.Any(sd => sd.ServiceType == typeof(IUAuthPasswordHasher))) - throw new InvalidOperationException("No IUAuthPasswordHasher registered. Call UseArgon2() or another hasher."); - - //if (!services.Any(sd => sd.ServiceType.IsAssignableTo(typeof(IUAuthUserStore<>)))) - // throw new InvalidOperationException("No credential store registered."); - - if (!services.Any(sd => sd.ServiceType.IsAssignableTo(typeof(ISessionStore)))) - throw new InvalidOperationException("No session store registered."); - - return services; - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/ResolvedCredential.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/ResolvedCredential.cs index 98c509f8..b6939cd5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Contracts/ResolvedCredential.cs +++ b/src/CodeBeam.UltimateAuth.Server/Contracts/ResolvedCredential.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Server.Contracts; public sealed record ResolvedCredential { - public PrimaryGrantKind Kind { get; init; } + public PrimaryTokenKind Kind { get; init; } /// /// Raw credential value (session id / jwt / opaque) diff --git a/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthDiagnostic.cs b/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthDiagnostic.cs index b993991a..386388b0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthDiagnostic.cs +++ b/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthDiagnostic.cs @@ -4,7 +4,7 @@ public sealed record UAuthDiagnostic(string code, string message, UAuthDiagnosti public enum UAuthDiagnosticSeverity { - Info, - Warning, - Error + Info = 0, + Warning = 10, + Error = 20 } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs index 8e43a97b..fa21263e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs @@ -19,6 +19,12 @@ public interface IUserEndpointHandler Task GetUserAsync(UserKey userKey, HttpContext ctx); Task UpdateUserAsync(UserKey userKey, HttpContext ctx); + Task CreateProfileSelfAsync(HttpContext ctx); + Task DeleteProfileSelfAsync(HttpContext ctx); + + Task CreateProfileAdminAsync(UserKey userKey, HttpContext ctx); + Task DeleteProfileAdminAsync(UserKey userKey, HttpContext ctx); + Task GetMyIdentifiersAsync(HttpContext ctx); Task IdentifierExistsSelfAsync(HttpContext ctx); Task AddUserIdentifierSelfAsync(HttpContext ctx); diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/LoginEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/LoginEndpointHandlerBridge.cs deleted file mode 100644 index 9639a42f..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/LoginEndpointHandlerBridge.cs +++ /dev/null @@ -1,16 +0,0 @@ -//using CodeBeam.UltimateAuth.Core.Domain; -//using Microsoft.AspNetCore.Http; - -//namespace CodeBeam.UltimateAuth.Server.Endpoints; - -//internal sealed class LoginEndpointHandlerBridge : ILoginEndpointHandler -//{ -// private readonly LoginEndpointHandler _inner; - -// public LoginEndpointHandlerBridge(LoginEndpointHandler inner) -// { -// _inner = inner; -// } - -// public Task LoginAsync(HttpContext ctx) => _inner.LoginAsync(ctx); -//} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/LogoutEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/LogoutEndpointHandlerBridge.cs deleted file mode 100644 index 710cf06e..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/LogoutEndpointHandlerBridge.cs +++ /dev/null @@ -1,17 +0,0 @@ -//using CodeBeam.UltimateAuth.Core.Domain; -//using Microsoft.AspNetCore.Http; - -//namespace CodeBeam.UltimateAuth.Server.Endpoints; - -//internal sealed class LogoutEndpointHandlerBridge : ILogoutEndpointHandler -//{ -// private readonly LogoutEndpointHandler _inner; - -// public LogoutEndpointHandlerBridge(LogoutEndpointHandler inner) -// { -// _inner = inner; -// } - -// public Task LogoutAsync(HttpContext ctx) -// => _inner.LogoutAsync(ctx); -//} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/PkceEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/PkceEndpointHandlerBridge.cs deleted file mode 100644 index 1b5aef95..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/PkceEndpointHandlerBridge.cs +++ /dev/null @@ -1,18 +0,0 @@ -//using CodeBeam.UltimateAuth.Core.Domain; -//using Microsoft.AspNetCore.Http; - -//namespace CodeBeam.UltimateAuth.Server.Endpoints; - -//internal sealed class PkceEndpointHandlerBridge : IPkceEndpointHandler -//{ -// private readonly PkceEndpointHandler _inner; - -// public PkceEndpointHandlerBridge(PkceEndpointHandler inner) -// { -// _inner = inner; -// } - -// public Task AuthorizeAsync(HttpContext ctx) => _inner.AuthorizeAsync(ctx); - -// public Task CompleteAsync(HttpContext ctx) => _inner.CompleteAsync(ctx); -//} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/RefreshEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/RefreshEndpointHandlerBridge.cs deleted file mode 100644 index 9a23cc1b..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/RefreshEndpointHandlerBridge.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Endpoints; - -internal sealed class RefreshEndpointHandlerBridge : IRefreshEndpointHandler -{ - private readonly RefreshEndpointHandler _inner; - - public RefreshEndpointHandlerBridge(RefreshEndpointHandler inner) - { - _inner = inner; - } - - public Task RefreshAsync(HttpContext ctx) => _inner.RefreshAsync(ctx); -} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/ValidateEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/ValidateEndpointHandlerBridge.cs deleted file mode 100644 index 412bcef8..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/ValidateEndpointHandlerBridge.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Endpoints; - -internal sealed class ValidateEndpointHandlerBridge : IValidateEndpointHandler -{ - private readonly ValidateEndpointHandler _inner; - - public ValidateEndpointHandlerBridge(ValidateEndpointHandler inner) - { - _inner = inner; - } - - public Task ValidateAsync(HttpContext context, CancellationToken ct = default) => _inner.ValidateAsync(context, ct); -} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index fa1d0120..4ba6e369 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -220,21 +220,37 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options if (options.Endpoints.UserProfile != false) { if (Enabled(UAuthActions.UserProfiles.GetSelf)) - self.MapPost("/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + self.MapPost("/profile/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.GetMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + if (Enabled(UAuthActions.UserProfiles.CreateSelf)) + self.MapPost("/profile/create", async (IUserEndpointHandler h, HttpContext ctx) + => await h.CreateProfileSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + if (Enabled(UAuthActions.UserProfiles.UpdateSelf)) - self.MapPost("/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + self.MapPost("/profile/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.UpdateMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + if (Enabled(UAuthActions.UserProfiles.DeleteSelf)) + self.MapPost("/profile/delete", async (IUserEndpointHandler h, HttpContext ctx) + => await h.DeleteProfileSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + if (Enabled(UAuthActions.UserProfiles.GetAdmin)) adminUsers.MapPost("/{userKey}/profile/get", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.GetUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + if (Enabled(UAuthActions.UserProfiles.CreateAdmin)) + adminUsers.MapPost("/{userKey}/profile/create", async (IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.CreateProfileAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + if (Enabled(UAuthActions.UserProfiles.UpdateAdmin)) adminUsers.MapPost("/{userKey}/profile/update", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.UpdateUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + + if (Enabled(UAuthActions.UserProfiles.DeleteAdmin)) + adminUsers.MapPost("/{userKey}/profile/delete", async (IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.DeleteProfileAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); } if (options.Endpoints.UserIdentifier != false) diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs index 9473ee3a..f95fd6fa 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs @@ -46,7 +46,7 @@ public async Task ValidateAsync(HttpContext context, CancellationToken ); } - if (credential.Kind == PrimaryGrantKind.Stateful) + if (credential.Kind == PrimaryTokenKind.Session) { if (!AuthSessionId.TryCreate(credential.Value, out var sessionId)) { diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ClaimsSnapshotExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ClaimsSnapshotExtensions.cs deleted file mode 100644 index d0e17f40..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ClaimsSnapshotExtensions.cs +++ /dev/null @@ -1,10 +0,0 @@ -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/HttpContext/HttpContextRequestExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextRequestExtensions.cs index 480e882b..46b06bf4 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextRequestExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextRequestExtensions.cs @@ -1,17 +1,16 @@ -using Microsoft.AspNetCore.Http; +using CodeBeam.UltimateAuth.Core.Defaults; +using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Extensions; internal static class HttpContextRequestExtensions { - private const string FormCacheKey = "__uauth_form"; - public static async Task GetCachedFormAsync(this HttpContext ctx) { if (!ctx.Request.HasFormContentType) return null; - if (ctx.Items.TryGetValue(FormCacheKey, out var existing) && existing is IFormCollection cached) + if (ctx.Items.TryGetValue(UAuthConstants.Form.FormCacheKey, out var existing) && existing is IFormCollection cached) return cached; try @@ -19,7 +18,7 @@ internal static class HttpContextRequestExtensions ctx.Request.EnableBuffering(); var form = await ctx.Request.ReadFormAsync(); ctx.Request.Body.Position = 0; - ctx.Items[FormCacheKey] = form; + ctx.Items[UAuthConstants.Form.FormCacheKey] = form; return form; } catch (IOException) diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index a9da44d6..b9cf26dc 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -279,7 +279,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol options.DefaultChallengeScheme ??= UAuthConstants.SchemeDefaults.GlobalScheme; }); - services.AddAuthentication().AddUAuthCookies(); + services.AddAuthentication().AddUAuthScheme(); services.AddAuthorization(); services.AddSingleton(); diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionKind.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionKind.cs index e4044bd7..2c8a0c4c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionKind.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionKind.cs @@ -2,7 +2,7 @@ public enum LoginDecisionKind { - Allow = 1, - Deny = 2, - Challenge = 3 + Allow = 0, + Deny = 10, + Challenge = 20 } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginExecutionMode.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginExecutionMode.cs index 56892530..2987ec57 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginExecutionMode.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginExecutionMode.cs @@ -3,5 +3,5 @@ internal enum LoginExecutionMode { Preview = 0, - Commit = 1 + Commit = 10 } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceChallengeMethod.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceChallengeMethod.cs index 287d8406..4d550d37 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceChallengeMethod.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceChallengeMethod.cs @@ -2,5 +2,5 @@ public enum PkceChallengeMethod { - S256 + S256 = 0 } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationFailureReason.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationFailureReason.cs index 29a6eef5..a77e1bb2 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationFailureReason.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationFailureReason.cs @@ -2,10 +2,10 @@ public enum PkceValidationFailureReason { - None, - ArtifactExpired, - MaxAttemptsExceeded, - UnsupportedChallengeMethod, - InvalidVerifier, - ContextMismatch + None = 0, + ArtifactExpired = 10, + MaxAttemptsExceeded = 20, + UnsupportedChallengeMethod = 30, + InvalidVerifier = 40, + ContextMismatch = 50 } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshDecision.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshDecision.cs index 00c9eb6d..8620026f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshDecision.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshDecision.cs @@ -17,7 +17,7 @@ public enum RefreshDecision /// No access / refresh token issued. /// (PureOpaque) /// - SessionTouch = 1, + SessionTouch = 10, /// /// Refresh token is rotated and @@ -25,5 +25,5 @@ public enum RefreshDecision /// Session MAY also be touched depending on policy. /// (Hybrid, SemiHybrid, PureJwt) /// - TokenRotation = 2 + TokenRotation = 20 } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshEvaluationResult.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshEvaluationResult.cs deleted file mode 100644 index f5a7f856..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshEvaluationResult.cs +++ /dev/null @@ -1,5 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Server.Flows; - -internal sealed record RefreshEvaluationResult(RefreshOutcome Outcome); diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshStrategyResolver.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshStrategyResolver.cs deleted file mode 100644 index 52da1aa4..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshStrategyResolver.cs +++ /dev/null @@ -1,20 +0,0 @@ -using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Contracts; -using System.Security; - -namespace CodeBeam.UltimateAuth.Server.Flows; - -public class RefreshStrategyResolver -{ - public static RefreshStrategy Resolve(UAuthMode mode) - { - return mode switch - { - UAuthMode.PureOpaque => RefreshStrategy.SessionOnly, - UAuthMode.PureJwt => RefreshStrategy.TokenOnly, - UAuthMode.SemiHybrid => RefreshStrategy.TokenWithSessionCheck, - UAuthMode.Hybrid => RefreshStrategy.SessionAndToken, - _ => throw new SecurityException("Unsupported refresh mode") - }; - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/AccessPolicyProvider.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/AccessPolicyProvider.cs index 0a991193..2c19190b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/AccessPolicyProvider.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/AccessPolicyProvider.cs @@ -17,5 +17,4 @@ public AccessPolicyProvider(CompiledAccessPolicySet set, IServiceProvider servic } public IReadOnlyCollection GetPolicies(AccessContext context) => _set.Resolve(context, _services); - } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialKind.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialKind.cs index a33ad8bd..870a458e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialKind.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialKind.cs @@ -2,8 +2,8 @@ public enum TransportCredentialKind { - Session, - AccessToken, - RefreshToken, - Hub + Session = 0, + AccessToken = 10, + RefreshToken = 20, + Hub = 30 } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/PrimaryCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/PrimaryCredentialResolver.cs index 3ba31678..0744a460 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/PrimaryCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/PrimaryCredentialResolver.cs @@ -16,7 +16,7 @@ public PrimaryCredentialResolver(IOptions options) _options = options.Value; } - public PrimaryGrantKind Resolve(HttpContext context) + public PrimaryTokenKind Resolve(HttpContext context) { if (IsApiRequest(context)) return _options.PrimaryCredential.Api; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/ValidateCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/ValidateCredentialResolver.cs index 1923e5c3..85a6c22c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/ValidateCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/ValidateCredentialResolver.cs @@ -23,8 +23,8 @@ public ValidateCredentialResolver(IPrimaryCredentialResolver primaryResolver) return kind switch { - PrimaryGrantKind.Stateful => await ResolveSession(context, response), - PrimaryGrantKind.Stateless => await ResolveAccessToken(context, response), + PrimaryTokenKind.Session => await ResolveSession(context, response), + PrimaryTokenKind.AccessToken => await ResolveAccessToken(context, response), _ => null }; @@ -49,7 +49,7 @@ public ValidateCredentialResolver(IPrimaryCredentialResolver primaryResolver) return new ResolvedCredential { - Kind = PrimaryGrantKind.Stateful, + Kind = PrimaryTokenKind.Session, Value = raw.Trim(), Tenant = context.GetTenant(), Device = await context.GetDeviceAsync() @@ -81,7 +81,7 @@ public ValidateCredentialResolver(IPrimaryCredentialResolver primaryResolver) return new ResolvedCredential { - Kind = PrimaryGrantKind.Stateless, + Kind = PrimaryTokenKind.AccessToken, Value = value, Tenant = context.GetTenant(), Device = await context.GetDeviceAsync() diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs index cb0b5955..d6986a1e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs @@ -95,7 +95,7 @@ private AccessToken IssueOpaqueAccessToken(DateTimeOffset expires, string? sessi return new AccessToken { Token = token, - Type = TokenType.Opaque, + Format = TokenFormat.Opaque, ExpiresAt = expires, SessionId = sessionId }; @@ -135,7 +135,7 @@ private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, UAuthToken return new AccessToken { Token = jwt, - Type = TokenType.Jwt, + Format = TokenFormat.Jwt, ExpiresAt = expires, SessionId = context.SessionId.ToString() }; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlKind.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlKind.cs index fa82e13d..52989cb5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlKind.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlKind.cs @@ -2,7 +2,7 @@ public enum ReturnUrlKind { - None, - Relative, - Absolute + None = 0, + Relative = 10, + Absolute = 20 } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs index d02e74b3..de1841c1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs @@ -3,7 +3,6 @@ using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Server.Extensions; -using CodeBeam.UltimateAuth.Server.Middlewares; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Infrastructure; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserId.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserId.cs deleted file mode 100644 index caf8cb45..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserId.cs +++ /dev/null @@ -1,11 +0,0 @@ -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/Options/UAuthHubDeploymentMode.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubDeploymentMode.cs index 2b90ae92..7fe26afd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubDeploymentMode.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubDeploymentMode.cs @@ -10,17 +10,17 @@ public enum UAuthHubDeploymentMode /// UAuthHub is embedded in the same application and same origin. /// Example: Blazor Server app hosting auth endpoints internally. /// - Embedded, + Embedded = 0, /// /// UAuthHub is hosted separately but within the same site boundary. /// Example: auth.company.com and app.company.com behind same-site policy. /// - Integrated, + Integrated = 10, /// /// UAuthHub is hosted on a different site / domain. /// Example: auth.vendor.com used by app.company.com. /// - External + External = 20 } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthPrimaryCredentialPolicy.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthPrimaryCredentialPolicy.cs index 685bd2a3..b6993a08 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthPrimaryCredentialPolicy.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthPrimaryCredentialPolicy.cs @@ -7,12 +7,12 @@ public sealed class UAuthPrimaryCredentialPolicy /// /// Default primary credential for UI-style requests. /// - public PrimaryGrantKind Ui { get; set; } = PrimaryGrantKind.Stateful; + public PrimaryTokenKind Ui { get; set; } = PrimaryTokenKind.Session; /// /// Default primary credential for API requests. /// - public PrimaryGrantKind Api { get; set; } = PrimaryGrantKind.Stateless; + public PrimaryTokenKind Api { get; set; } = PrimaryTokenKind.AccessToken; internal UAuthPrimaryCredentialPolicy Clone() => new() { diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index 6706f0d4..97652d24 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -95,6 +95,8 @@ public sealed class UAuthServerOptions public UAuthLoginIdentifierOptions LoginIdentifiers { get; set; } = new(); + public UAuthUserProfileOptions UserProfile { get; set; } = new(); + public UAuthNavigationOptions Navigation { get; set; } = new(); @@ -148,6 +150,7 @@ internal UAuthServerOptions Clone() Identifiers = Identifiers.Clone(), IdentifierValidation = IdentifierValidation.Clone(), LoginIdentifiers = LoginIdentifiers.Clone(), + UserProfile = UserProfile.Clone(), Endpoints = Endpoints.Clone(), Navigation = Navigation.Clone(), diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthUserProfileOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthUserProfileOptions.cs new file mode 100644 index 00000000..0a6ab660 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthUserProfileOptions.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Server.Options; + +public class UAuthUserProfileOptions +{ + public bool EnableMultiProfile { get; set; } = false; + + internal UAuthUserProfileOptions Clone() => new() + { + EnableMultiProfile = EnableMultiProfile + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceAuthContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceAuthContextFactory.cs index 7569b9e7..7d0ced82 100644 --- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceAuthContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceAuthContextFactory.cs @@ -25,6 +25,10 @@ public AuthContext Create(DateTimeOffset? at = null) var result = ctx.Items[UAuthConstants.HttpItems.SessionValidationResult] as SessionValidationResult; + DeviceContext device = result?.BoundDeviceId is { } deviceId + ? DeviceContext.Create(DeviceId.Create(deviceId.Value)) + : DeviceContext.Anonymous(); + if (result is null || !result.IsValid) { return new AuthContext @@ -33,7 +37,7 @@ public AuthContext Create(DateTimeOffset? at = null) Operation = AuthOperation.ResourceAccess, Mode = UAuthMode.PureOpaque, ClientProfile = UAuthClientProfile.Api, - Device = DeviceContext.Create(DeviceId.Create(result.BoundDeviceId.Value.Value)), + Device = device, At = at ?? _clock.UtcNow, Session = null }; @@ -43,15 +47,15 @@ public AuthContext Create(DateTimeOffset? at = null) { Tenant = result.Tenant, Operation = AuthOperation.ResourceAccess, - Mode = UAuthMode.PureOpaque, // sonra resolver yapılabilir + Mode = UAuthMode.PureOpaque, // TODO: Think about resolver. ClientProfile = UAuthClientProfile.Api, - Device = DeviceContext.Create(DeviceId.Create(result.BoundDeviceId.Value.Value)), + Device = device, At = at ?? _clock.UtcNow, Session = new SessionSecurityContext { UserKey = result.UserKey, - SessionId = result.SessionId.Value, + SessionId = result.SessionId!.Value, State = result.State, ChainId = result.ChainId, BoundDeviceId = result.BoundDeviceId diff --git a/src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfoProvider.cs b/src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfoProvider.cs index de9a7e0a..57605781 100644 --- a/src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfoProvider.cs +++ b/src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfoProvider.cs @@ -1,5 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Options; -using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Options; using Microsoft.Extensions.Options; using System.Reflection; diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs index ab4b6d4b..18adbfcd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs @@ -27,7 +27,7 @@ public async Task> ValidateAsync(string if (!result.IsValid) { - return TokenValidationResult.Invalid(TokenType.Jwt, MapJwtError(result.Exception)); + return TokenValidationResult.Invalid(TokenFormat.Jwt, MapJwtError(result.Exception)); } var jwt = (JsonWebToken)result.SecurityToken; @@ -38,7 +38,7 @@ public async Task> ValidateAsync(string var userIdString = jwt.GetClaim(ClaimTypes.NameIdentifier)?.Value ?? jwt.GetClaim("sub")?.Value; if (string.IsNullOrWhiteSpace(userIdString)) { - return TokenValidationResult.Invalid(TokenType.Jwt, TokenInvalidReason.MissingSubject); + return TokenValidationResult.Invalid(TokenFormat.Jwt, TokenInvalidReason.MissingSubject); } TUserId userId; @@ -48,7 +48,7 @@ public async Task> ValidateAsync(string } catch { - return TokenValidationResult.Invalid(TokenType.Jwt, TokenInvalidReason.Malformed); + return TokenValidationResult.Invalid(TokenFormat.Jwt, TokenInvalidReason.Malformed); } var tenantId = jwt.GetClaim("tenant")?.Value ?? jwt.GetClaim("tid")?.Value; @@ -60,7 +60,7 @@ public async Task> ValidateAsync(string } return TokenValidationResult.Valid( - type: TokenType.Jwt, + format: TokenFormat.Jwt, tenant: TenantKey.FromExternal(tenantId), userId, sessionId: sessionId, diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs index fe12bfcd..d7c52993 100644 --- a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs @@ -12,7 +12,7 @@ internal sealed class EfCoreAuthenticationSecurityStateStore : IAuth private readonly TDbContext _db; private readonly TenantKey _tenant; - public EfCoreAuthenticationSecurityStateStore(TDbContext db, TenantContext tenant) + public EfCoreAuthenticationSecurityStateStore(TDbContext db, TenantExecutionContext tenant) { _db = db; _tenant = tenant.Tenant; diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStoreFactory.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStoreFactory.cs index 5f897cf7..d254316c 100644 --- a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStoreFactory.cs +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStoreFactory.cs @@ -15,6 +15,6 @@ public EfCoreAuthenticationSecurityStateStoreFactory(TDbContext db) public IAuthenticationSecurityStateStore Create(TenantKey tenant) { - return new EfCoreAuthenticationSecurityStateStore(_db, new TenantContext(tenant)); + return new EfCoreAuthenticationSecurityStateStore(_db, new TenantExecutionContext(tenant)); } } diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs index 6a191aeb..503ed884 100644 --- a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs @@ -14,7 +14,7 @@ internal sealed class InMemoryAuthenticationSecurityStateStore : IAuthentication private readonly ConcurrentDictionary _byId = new(); private readonly ConcurrentDictionary<(UserKey, AuthenticationSecurityScope, CredentialType?), Guid> _index = new(); - public InMemoryAuthenticationSecurityStateStore(TenantContext tenant) + public InMemoryAuthenticationSecurityStateStore(TenantExecutionContext tenant) { _tenant = tenant.Tenant; } diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStoreFactory.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStoreFactory.cs index dfc34430..72dca09e 100644 --- a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStoreFactory.cs +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStoreFactory.cs @@ -10,6 +10,6 @@ internal sealed class InMemoryAuthenticationSecurityStateStoreFactory : IAuthent public IAuthenticationSecurityStateStore Create(TenantKey tenant) { - return _stores.GetOrAdd(tenant, t => new InMemoryAuthenticationSecurityStateStore(new TenantContext(t))); + return _stores.GetOrAdd(tenant, t => new InMemoryAuthenticationSecurityStateStore(new TenantExecutionContext(t))); } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs index f5151afa..3a0b5a30 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs @@ -11,7 +11,7 @@ internal sealed class EfCoreRoleStore : IRoleStore where TDbContext private readonly TDbContext _db; private readonly TenantKey _tenant; - public EfCoreRoleStore(TDbContext db, TenantContext tenant) + public EfCoreRoleStore(TDbContext db, TenantExecutionContext tenant) { _db = db; _tenant = tenant.Tenant; diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs index ed02cd5e..d6bf6afe 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs @@ -14,6 +14,6 @@ public EfCoreRoleStoreFactory(TDbContext db) public IRoleStore Create(TenantKey tenant) { - return new EfCoreRoleStore(_db, new TenantContext(tenant)); + return new EfCoreRoleStore(_db, new TenantExecutionContext(tenant)); } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStore.cs index a99234f0..a7f46971 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStore.cs @@ -11,7 +11,7 @@ internal sealed class EfCoreUserRoleStore : IUserRoleStore where TDb private readonly TDbContext _db; private readonly TenantKey _tenant; - public EfCoreUserRoleStore(TDbContext db, TenantContext tenant) + public EfCoreUserRoleStore(TDbContext db, TenantExecutionContext tenant) { _db = db; _tenant = tenant.Tenant; diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStoreFactory.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStoreFactory.cs index 74132289..a4920062 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStoreFactory.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStoreFactory.cs @@ -14,6 +14,6 @@ public EfCoreUserRoleStoreFactory(TDbContext db) public IUserRoleStore Create(TenantKey tenant) { - return new EfCoreUserRoleStore(_db, new TenantContext(tenant)); + return new EfCoreUserRoleStore(_db, new TenantExecutionContext(tenant)); } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs index 1c47b32f..11bde034 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs @@ -10,7 +10,7 @@ internal sealed class InMemoryRoleStore : InMemoryTenantVersionedStore new(entity.Tenant, entity.Id); - public InMemoryRoleStore(TenantContext tenant) : base(tenant) + public InMemoryRoleStore(TenantExecutionContext tenant) : base(tenant) { } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStoreFactory.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStoreFactory.cs index 570d507b..7b5b2df3 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStoreFactory.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStoreFactory.cs @@ -9,6 +9,6 @@ public sealed class InMemoryRoleStoreFactory : IRoleStoreFactory public IRoleStore Create(TenantKey tenant) { - return _stores.GetOrAdd(tenant, t => new InMemoryRoleStore(new TenantContext(t))); + return _stores.GetOrAdd(tenant, t => new InMemoryRoleStore(new TenantExecutionContext(t))); } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs index 8027360b..d681d2a0 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs @@ -11,7 +11,7 @@ internal sealed class InMemoryUserRoleStore : IUserRoleStore private readonly TenantKey _tenant; private readonly ConcurrentDictionary> _assignments = new(); - public InMemoryUserRoleStore(TenantContext tenant) + public InMemoryUserRoleStore(TenantExecutionContext tenant) { _tenant = tenant.Tenant; } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStoreFactory.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStoreFactory.cs index 9e8c8723..745ab6ef 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStoreFactory.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStoreFactory.cs @@ -9,6 +9,6 @@ public sealed class InMemoryUserRoleStoreFactory : IUserRoleStoreFactory public IUserRoleStore Create(TenantKey tenant) { - return _stores.GetOrAdd(tenant, t => new InMemoryUserRoleStore(new TenantContext(t))); + return _stores.GetOrAdd(tenant, t => new InMemoryUserRoleStore(new TenantExecutionContext(t))); } } diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs index bc66f3cb..63662572 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs @@ -58,8 +58,8 @@ public async Task SendFormAsync(string endpoint, IDictiona if (result.Status == 0) throw new UAuthTransportException("Network error."); - if (result.Status >= 500) - throw new UAuthTransportException($"Server error {result.Status}", (HttpStatusCode)result.Status); + //if (result.Status >= 500) + // throw new UAuthTransportException($"Server error {result.Status}", (HttpStatusCode)result.Status); return result; } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs index 7931121d..f08a09a3 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs @@ -2,26 +2,117 @@ namespace CodeBeam.UltimateAuth.Client.Options; +/// +/// Represents client-side configuration for UltimateAuth. +/// +/// +/// +/// This class defines how the client application interacts with the UltimateAuth server, +/// including authentication flows, endpoints, client behavior, and multi-tenant support. +/// +/// +/// +/// The most important concept is , which determines +/// how authentication behaves depending on the client type (e.g., Blazor Server, WASM, API). +/// +/// +/// +/// Key areas: +/// +/// Client Profile: Controls auth mode (session vs token, PKCE, etc.) +/// Flows: Login, PKCE, refresh, and reauthentication behavior +/// Endpoints: Server route configuration +/// State Events: Client-side state change notifications +/// Multi-Tenancy: Tenant resolution and propagation +/// +/// +/// +/// +/// Important: +/// +/// If is enabled, the profile is inferred automatically. +/// If disabled, must be explicitly set. +/// Different profiles may result in different authentication modes (e.g., PureOpaque vs Hybrid). +/// +/// +/// public sealed class UAuthClientOptions { + /// + /// Specifies the client profile used for authentication behavior. + /// + /// + /// Determines how authentication flows are executed (e.g., session-based, PKCE, token-based). + /// public UAuthClientProfile ClientProfile { get; set; } = UAuthClientProfile.NotSpecified; + + /// + /// Enables automatic detection of the client profile. + /// + /// + /// When enabled, UltimateAuth infers the client type (e.g., Blazor Server, WASM). + /// Disable this to guarantee explicitly control behavior via . + /// public bool AutoDetectClientProfile { get; set; } = true; /// - /// Global fallback return URL used by interactive authentication flows - /// when no flow-specific return URL is provided. + /// Default return URL used for interactive authentication flows. /// + /// + /// Used when no return URL is explicitly provided in login or PKCE flows. + /// public string? DefaultReturnUrl { get; set; } + /// + /// Configures client-side state change events. + /// + /// + /// Controls how authentication-related events (e.g., login, logout, profile changes) are propagated and handled within the client. + /// public UAuthStateEventOptions StateEvents { get; set; } = new(); + + /// + /// Defines server endpoint paths used by the client. + /// + /// + /// Allows customization of API routes for authentication and user operations. + /// public UAuthClientEndpointOptions Endpoints { get; set; } = new(); + + /// + /// Options related to login flow behavior. + /// + /// + /// Controls how login requests are executed and handled on the client. + /// public UAuthClientLoginFlowOptions Login { get; set; } = new(); /// /// Options related to PKCE-based login flows. /// public UAuthClientPkceLoginFlowOptions Pkce { get; set; } = new(); + + /// + /// Configures automatic session/token refresh behavior. + /// + /// + /// Determines how and when refresh operations are triggered. + /// public UAuthClientAutoRefreshOptions AutoRefresh { get; set; } = new(); + + /// + /// Options for reauthentication behavior. + /// + /// + /// Used when session becomes invalid and user interaction is required again. + /// public UAuthClientReauthOptions Reauth { get; init; } = new(); + + /// + /// Configures multi-tenant behavior for the client. + /// + /// + /// Controls how tenant information is resolved and included in requests. + /// public UAuthClientMultiTenantOptions MultiTenant { get; set; } = new(); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs index 78b6ffd9..3e848763 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs @@ -4,17 +4,109 @@ namespace CodeBeam.UltimateAuth.Client.Services; +/// +/// Provides authorization and role management operations for the current application. +/// +/// +/// +/// This client is responsible for evaluating permissions, managing roles, +/// and assigning authorization policies to users. +/// +/// +/// +/// Key capabilities: +/// +/// Permission checks via +/// User role management (assign/remove roles) +/// Role lifecycle management (create, rename, delete) +/// Permission assignment to roles +/// +/// +/// +/// +/// Important: +/// +/// Authorization decisions are evaluated on the server and may depend on current session context. +/// Role changes may not take effect immediately for active sessions depending on caching strategy. +/// Multi-tenant isolation is enforced; all operations are scoped to the current tenant. +/// +/// +/// public interface IAuthorizationClient { + /// + /// Evaluates whether the current user is authorized to perform a specific action. + /// + /// + /// This method performs a server-side authorization check based on the current session, + /// roles, and assigned permissions. + /// Task> CheckAsync(AuthorizationCheckRequest request); + + /// + /// Retrieves roles assigned to the current user. + /// + /// + /// Results may be paginated. Use to control paging behavior. + /// Task> GetMyRolesAsync(PageRequest? request = null); + + /// + /// Retrieves roles assigned to a specific user. + /// + /// + /// Requires appropriate administrative permissions. + /// Task> GetUserRolesAsync(UserKey userKey, PageRequest? request = null); + + /// + /// Assigns a role to a user. + /// + /// + /// The role must exist and the caller must have sufficient privileges. + /// Task AssignRoleToUserAsync(AssignRoleRequest request); + + /// + /// Removes a role from a user. + /// Task RemoveRoleFromUserAsync(RemoveRoleRequest request); + + /// + /// Creates a new role. + /// + /// + /// Role names must be unique (case-insensitive) within a tenant. + /// Task> CreateRoleAsync(CreateRoleRequest request); + + /// + /// Queries roles with filtering and pagination. + /// Task>> QueryRolesAsync(RoleQuery request); + + /// + /// Renames an existing role. + /// + /// + /// May fail if the target name already exists. + /// Task RenameRoleAsync(RenameRoleRequest request); + + /// + /// Sets the permissions associated with a role. + /// + /// + /// This operation replaces existing permissions. + /// Task SetRolePermissionsAsync(SetRolePermissionsRequest request); + + /// + /// Deletes a role. + /// + /// + /// Deleting a role removes it from all users. + /// Task> DeleteRoleAsync(DeleteRoleRequest request); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs index b9241513..5715505d 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs @@ -4,18 +4,112 @@ namespace CodeBeam.UltimateAuth.Client.Services; +/// +/// Provides credential management operations such as password creation, update, revocation, and reset flows. +/// +/// +/// +/// This client handles both self-service and administrative credential operations. +/// +/// +/// +/// Key capabilities: +/// +/// Credential creation and update +/// Credential revocation +/// Credential reset flows (begin/complete) +/// +/// +/// +/// +/// Important: +/// +/// Reset operations are typically multi-step (begin → complete). +/// Self methods operate on the current authenticated user. +/// User methods require administrative privileges. +/// Credential changes may invalidate active sessions depending on security policy. +/// +/// +/// public interface ICredentialClient { + /// + /// Adds a new credential for the current user. + /// + /// + /// Typically used for initial password setup or adding alternative credentials. + /// Task> AddMyAsync(AddCredentialRequest request); + + /// + /// Changes the credential of the current user. + /// + /// + /// May require the current credential for verification depending on policy. + /// Task> ChangeMyAsync(ChangeCredentialRequest request); + + /// + /// Revokes the current user's credential. + /// + /// + /// Revocation may invalidate active sessions. + /// Task RevokeMyAsync(RevokeCredentialRequest request); + + /// + /// Starts the credential reset process for the current user. + /// + /// + /// This typically issues a verification step (e.g., email or OTP). + /// Task> BeginResetMyAsync(BeginResetCredentialRequest request); + + /// + /// Completes the credential reset process for the current user. + /// + /// + /// Must be called after a successful . + /// Task> CompleteResetMyAsync(CompleteResetCredentialRequest request); + + /// + /// Adds a credential for a specific user. + /// + /// + /// Requires administrative privileges. + /// Task> AddUserAsync(UserKey userKey, AddCredentialRequest request); + + /// + /// Changes the credential of a specific user. + /// + /// + /// Typically used for administrative resets or overrides. + /// Task> ChangeUserAsync(UserKey userKey, ChangeCredentialRequest request); + + /// + /// Revokes a credential for a specific user. + /// Task RevokeUserAsync(UserKey userKey, RevokeCredentialRequest request); + + /// + /// Starts the credential reset process for a specific user. + /// Task> BeginResetUserAsync(UserKey userKey, BeginResetCredentialRequest request); + + /// + /// Completes the credential reset process for a specific user. + /// Task> CompleteResetUserAsync(UserKey userKey, CompleteResetCredentialRequest request); + + /// + /// Deletes a credential associated with a specific user. + /// + /// + /// This removes softly or permanently the credential from the system. + /// Task DeleteUserAsync(UserKey userKey, DeleteCredentialRequest request); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs index f2cda9e5..2387d852 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs @@ -6,24 +6,135 @@ // TODO: Add ReauthAsync namespace CodeBeam.UltimateAuth.Client.Services; +/// +/// Provides authentication flow operations such as login, logout, session validation, +/// refresh, and PKCE-based authentication. +/// +/// +/// +/// This client is responsible for managing the full authentication lifecycle. +/// It abstracts different auth modes (e.g., session-based, token-based, PKCE). +/// +/// +/// +/// Key capabilities: +/// +/// Login and logout flows +/// Session validation and refresh +/// PKCE-based authentication (for WASM and public clients) +/// Device and session revocation +/// +/// +/// +/// +/// Important: +/// +/// Behavior depends on client profile (e.g., Blazor Server vs WASM). +/// Login may result in redirects or cookie/token updates. +/// Refresh behavior differs between PureOpaque and Hybrid modes. +/// Session state is managed server-side and may expire independently. +/// +/// +/// public interface IFlowClient { + /// + /// Performs a login operation. + /// + /// + /// This method triggers redirects depending on the client profile and configuration. + /// Task LoginAsync(LoginRequest request, string? returnUrl = null); + + /// + /// Attempts to log in and returns a structured result instead of throwing. UltimateAuth suggestion as better UX. + /// + /// + /// Redirects only on successful login if mode is TryAndCommit. In TryOnly mode, it returns the result without redirecting. + /// DirectCommit mode behaves same as LoginAsync. + /// Task TryLoginAsync(LoginRequest request, UAuthSubmitMode mode, string? returnUrl = null); + /// + /// Logs out the current user. + /// + /// + /// This clears the current session and may trigger redirects. + /// Task LogoutAsync(); + + /// + /// Refreshes the current session or tokens. + /// + /// + /// Behavior depends on auth mode: + /// + /// PureOpaque: touches session + /// Hybrid: rotates tokens + /// + /// Task RefreshAsync(bool isAuto = false); - //Task ReauthAsync(); + + /// + /// Validates the current authentication state. + /// + /// + /// Can be used to check if the current session is still valid. For UI refresh, consider using IUAuthStateManager instead. + /// Task ValidateAsync(); + /// + /// Starts a PKCE authentication flow and navigates to UAuthHub. + /// + /// + /// Typically used in public clients such as Blazor WASM. + /// Task BeginPkceAsync(string? returnUrl = null); - Task TryCompletePkceLoginAsync(PkceCompleteRequest request, UAuthSubmitMode mode); + + /// + /// Completes a PKCE login flow. + /// + /// + /// Must be called after . + /// Task CompletePkceLoginAsync(PkceCompleteRequest request); + /// + /// Attempts to complete a PKCE login flow. + /// + /// + /// Redirects only on successful PKCE login if mode is TryAndCommit. In TryOnly mode, it returns the result without redirecting. + /// DirectCommit mode behaves same as CompletePkceLoginAsync. + /// + Task TryCompletePkceLoginAsync(PkceCompleteRequest request, UAuthSubmitMode mode); + + /// + /// Logs out the given device session of current user. + /// Task> LogoutMyDeviceAsync(LogoutDeviceRequest request); + + /// + /// Logs out all other sessions except the current one of current user. + /// Task LogoutMyOtherDevicesAsync(); + + /// + /// Logs out all sessions of the current user. + /// Task LogoutAllMyDevicesAsync(); + + /// + /// Logs out a specific device session for a user. + /// Task> LogoutUserDeviceAsync(UserKey userKey, LogoutDeviceRequest request); + + /// + /// Logs out all other sessions for a user. Only given chain remains active. + /// Task LogoutUserOtherDevicesAsync(UserKey userKey, LogoutOtherDevicesRequest request); + + /// + /// Logs out all sessions for a user. + /// Task LogoutAllUserDevicesAsync(UserKey userKey); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs index 1ecb6eb4..582f3243 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs @@ -3,19 +3,120 @@ namespace CodeBeam.UltimateAuth.Client.Services; +/// +/// Provides session and device management operations for the current user or administrators. +/// +/// +/// +/// This client exposes the session model of UltimateAuth, which is based on: +/// +/// Root: Represents the user's global session authority. +/// Chain: Represents a device or client context. +/// Session: Represents an individual authentication instance. +/// +/// +/// +/// +/// Key capabilities: +/// +/// List active sessions (by device) +/// Inspect session details +/// Revoke sessions at different levels (session, chain, root) +/// +/// +/// +/// +/// Important: +/// +/// Revoking is different with logout. Revoke removes trust on device and new login creates a new chain instead of continue on current. +/// Revoking a root is the ultimate tool which should use on security-critital situations. +/// Session state is server-controlled and may expire independently. +/// Administrative methods require elevated permissions. +/// +/// +/// public interface ISessionClient { + /// + /// Retrieves chains (devices) summary for the current user. + /// + /// + /// Each chain represents a device or client context. + /// Task>> GetMyChainsAsync(PageRequest? request = null); + + /// + /// Retrieves detailed information about a specific session chain. + /// + /// + /// Includes session history and device-related information. + /// Task> GetMyChainDetailAsync(SessionChainId chainId); + + /// + /// Revokes a specific session chain (device). + /// + /// + /// This logs out the user from the specified device. + /// Task> RevokeMyChainAsync(SessionChainId chainId); + + /// + /// Revokes all session chains except the current one. + /// + /// + /// Useful for "log out from other devices" scenarios. + /// Task RevokeMyOtherChainsAsync(); + + /// + /// Revokes all session chains for the current user. + /// + /// + /// This logs out the user from all devices with clearing all device trusts. + /// Task RevokeAllMyChainsAsync(); + /// + /// Retrieves session chains (devices) for a specific user. + /// + /// + /// Requires administrative privileges. + /// Task>> GetUserChainsAsync(UserKey userKey, PageRequest? request = null); + + /// + /// Retrieves detailed session chain information for a specific user. + /// Task> GetUserChainDetailAsync(UserKey userKey, SessionChainId chainId); + + /// + /// Revokes a specific session instance for a user. + /// + /// + /// This invalidates a single session without affecting the entire device (chain). + /// Task RevokeUserSessionAsync(UserKey userKey, AuthSessionId sessionId); + + /// + /// Revokes a session chain (device) for a user. + /// Task> RevokeUserChainAsync(UserKey userKey, SessionChainId chainId); + + /// + /// Revokes the root session for a user. + /// + /// + /// This invalidates all sessions and chains for the user. + /// Task RevokeUserRootAsync(UserKey userKey); + + /// + /// Revokes all session chains (devices) for a user. + /// + /// + /// Equivalent to logging the user out from all devices. + /// Task RevokeAllUserChainsAsync(UserKey userKey); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUAuthClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUAuthClient.cs index 54dcf6bd..54cabf73 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUAuthClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUAuthClient.cs @@ -2,12 +2,66 @@ namespace CodeBeam.UltimateAuth.Client; +/// +/// Entry point for interacting with UltimateAuth from client applications. +/// Provides access to all authentication, user, session, and authorization operations. +/// +/// +/// +/// This client is designed to work across different client profiles (Blazor Server, WASM, MAUI, MVC, API). +/// Behavior may vary depending on the configured ClientProfile. +/// +/// +/// +/// Key components: +/// +/// : Handles login, logout, refresh and auth flows. +/// : Manages session lifecycle and validation. +/// : User profile and account operations. +/// : Email, username, phone management. +/// : Password and credential operations. +/// : Permission and policy checks. +/// +/// +/// +/// +/// Important: +/// +/// Session-based flows may rely on cookies (Blazor Server) or tokens (WASM). +/// State changes (login, logout, profile updates) may trigger client events. +/// Multi-tenant behavior depends on client configuration. +/// +/// +/// public interface IUAuthClient { + /// + /// Provides authentication flow operations such as login, logout, and refresh. + /// IFlowClient Flows { get; } + + /// + /// Provides access to session lifecycle operations. + /// ISessionClient Sessions { get; } + + /// + /// Provides user profile and account management operations. + /// IUserClient Users { get; } + + /// + /// Manages user identifiers such as email, username, and phone. + /// IUserIdentifierClient Identifiers { get; } + + /// + /// Provides credential operations such as password management. + /// ICredentialClient Credentials { get; } + + /// + /// Provides authorization and policy evaluation operations. + /// IAuthorizationClient Authorization { get; } } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs index 3034f685..ed06416b 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs @@ -4,19 +4,139 @@ namespace CodeBeam.UltimateAuth.Client.Services; +/// +/// Provides user management and profile operations for both self-service and administrative scenarios. +/// +/// +/// +/// This client handles user lifecycle operations as well as profile management. +/// +/// +/// +/// UltimateAuth supports multi-identifier and multi-profile per user: +/// +/// Each user can have multiple profiles (e.g., "default", "business"). You can enable it with server options. +/// Profile selection is controlled via . +/// If no profile is specified, the default profile is used. +/// +/// +/// +/// +/// Key capabilities: +/// +/// User creation and deletion +/// User status management +/// Profile retrieval and updates +/// Multi-profile creation and deletion +/// +/// +/// +/// +/// Important: +/// +/// Self methods operate on the current authenticated user. +/// Admin methods require elevated permissions. +/// Deleting a user removes all associated profiles. +/// Default profile cannot be deleted. +/// +/// +/// public interface IUserClient { + /// + /// Queries users with filtering and pagination. + /// Task>> QueryAsync(UserQuery query); + + /// + /// Creates a new user as the current user context. + /// + /// + /// Behavior may vary depending on client permissions and configuration. + /// Self creation may be allowed or restricted based on server settings. + /// Task> CreateAsync(CreateUserRequest request); + + /// + /// Creates a new user with administrative privileges. + /// Task> CreateAsAdminAsync(CreateUserRequest request); + + /// + /// Changes the status of the current user. + /// Task> ChangeMyStatusAsync(ChangeUserStatusSelfRequest request); + + /// + /// Changes the status of a specific user. + /// Task> ChangeUserStatusAsync(UserKey userKey, ChangeUserStatusAdminRequest request); + + /// + /// Deletes the current user. This is a soft-delete operation. Only administrators can restore deleted users or permanently removes soft deleted users. + /// + /// + /// This operation removes all associated profiles and sessions. + /// Task DeleteMeAsync(); + + /// + /// Deletes a specific user. + /// Task> DeleteUserAsync(UserKey userKey, DeleteUserRequest request); - Task> GetMeAsync(); + + /// + /// Retrieves the current user's profile. + /// + /// + /// If is null, the default profile is returned. + /// + Task> GetMeAsync(GetProfileRequest? request = null); + + /// + /// Updates the current user's profile. + /// + /// + /// The target profile is determined by . + /// Task UpdateMeAsync(UpdateProfileRequest request); - Task> GetUserAsync(UserKey userKey); + /// + /// Creates a new profile for the current user. + /// + /// + /// Profile keys must be unique per user. + /// Default profile is automatically created on user creation and cannot be duplicated. + /// + Task CreateMyProfileAsync(CreateProfileRequest request); + + /// + /// Deletes a profile of the current user. + /// + /// + /// The default profile cannot be deleted. + /// + Task DeleteMyProfileAsync(ProfileKey profileKey); + + + /// + /// Retrieves a profile of a specific user. + /// + Task> GetUserAsync(UserKey userKey, GetProfileRequest? request = null); + + /// + /// Updates a profile of a specific user. + /// Task UpdateUserAsync(UserKey userKey, UpdateProfileRequest request); + + /// + /// Creates a profile for a specific user. + /// + Task CreateUserProfileAsync(UserKey userKey, CreateProfileRequest request); + + /// + /// Deletes a profile of a specific user. + /// + Task DeleteUserProfileAsync(UserKey userKey, ProfileKey profileKey); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs index 6ce76868..96dfd2bf 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs @@ -4,21 +4,122 @@ namespace CodeBeam.UltimateAuth.Client.Services; +/// +/// Provides operations for managing user identifiers such as email, username, and phone. +/// +/// +/// +/// Identifiers represent login and contact points for a user (e.g., email, username, phone). +/// Each identifier has a type, value, and verification state. +/// +/// +/// +/// Key capabilities: +/// +/// Add, update, and delete identifiers +/// Mark identifiers as primary +/// Verify identifiers (e.g., email or phone verification) +/// +/// +/// +/// +/// Important: +/// +/// Identifier values are normalized and must be unique per tenant. +/// Only one primary identifier per type is allowed. +/// Verification is required for sensitive operations depending on policy. +/// Self methods operate on the current user; user methods require administrative privileges. +/// +/// +/// public interface IUserIdentifierClient { + /// + /// Retrieves identifiers of the current user. + /// Task>> GetMyAsync(PageRequest? request = null); + + /// + /// Adds a new identifier to the current user. + /// + /// + /// The identifier must be unique within the tenant. + /// Task AddMyAsync(AddUserIdentifierRequest request); + + /// + /// Updates an existing identifier of the current user. + /// + /// + /// May require re-verification depending on the change. + /// Task UpdateMyAsync(UpdateUserIdentifierRequest request); + + /// + /// Marks an identifier as primary for the current user. + /// + /// + /// Only one primary identifier per type is allowed. + /// Task SetMyPrimaryAsync(SetPrimaryUserIdentifierRequest request); + + /// + /// Removes the primary designation from an identifier. + /// + /// + /// At least one primary identifier may be required depending on system policy. + /// Task UnsetMyPrimaryAsync(UnsetPrimaryUserIdentifierRequest request); + + /// + /// Verifies an identifier of the current user. + /// + /// + /// Typically used for email or phone verification flows. + /// Task VerifyMyAsync(VerifyUserIdentifierRequest request); + + /// + /// Deletes an identifier of the current user. + /// + /// + /// Primary identifiers may need to be reassigned before deletion. + /// Task DeleteMyAsync(DeleteUserIdentifierRequest request); + + /// + /// Retrieves identifiers of a specific user. + /// Task>> GetUserAsync(UserKey userKey, PageRequest? request = null); + + /// + /// Adds an identifier to a specific user. + /// Task AddUserAsync(UserKey userKey, AddUserIdentifierRequest request); + + /// + /// Updates an identifier of a specific user. + /// Task UpdateUserAsync(UserKey userKey, UpdateUserIdentifierRequest request); + + /// + /// Marks an identifier as primary for a specific user. + /// Task SetUserPrimaryAsync(UserKey userKey, SetPrimaryUserIdentifierRequest request); + + /// + /// Removes the primary designation from an identifier of a specific user. + /// Task UnsetUserPrimaryAsync(UserKey userKey, UnsetPrimaryUserIdentifierRequest request); + + /// + /// Verifies an identifier for a specific user. + /// Task VerifyUserAsync(UserKey userKey, VerifyUserIdentifierRequest request); + + /// + /// Deletes an identifier of a specific user. + /// Task DeleteUserAsync(UserKey userKey, DeleteUserIdentifierRequest request); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs index 988c0ab2..cf9bb88e 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -330,7 +330,7 @@ public async Task CompletePkceLoginAsync(PkceCompleteRequest request) { ["authorization_code"] = request.AuthorizationCode, ["code_verifier"] = request.CodeVerifier, - ["return_url"] = request.ReturnUrl, + ["return_url"] = request.ReturnUrl ?? string.Empty, ["Identifier"] = request.Identifier ?? string.Empty, ["Secret"] = request.Secret ?? string.Empty, diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs index 001afe2e..14c67252 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs @@ -23,15 +23,16 @@ public UAuthUserClient(IUAuthRequestClient request, IUAuthClientEvents events, I private string Url(string path) => UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); - public async Task> GetMeAsync() + public async Task> GetMeAsync(GetProfileRequest? request = null) { - var raw = await _request.SendFormAsync(Url("/me/get")); + request ??= new GetProfileRequest(); + var raw = await _request.SendJsonAsync(Url("/me/profile/get"), request); return UAuthResultMapper.FromJson(raw); } public async Task UpdateMeAsync(UpdateProfileRequest request) { - var raw = await _request.SendJsonAsync(Url("/me/update"), request); + var raw = await _request.SendJsonAsync(Url("/me/profile/update"), request); if (raw.Ok) { await _events.PublishAsync(new UAuthStateEventArgs(UAuthStateEvent.ProfileChanged, _options.StateEvents.HandlingMode, request)); @@ -56,12 +57,69 @@ public async Task>> QueryAsync(UserQuery qu return UAuthResultMapper.FromJson>(raw); } + public async Task CreateMyProfileAsync(CreateProfileRequest request) + { + var raw = await _request.SendJsonAsync(Url("/me/profile/create"), request); + + if (raw.Ok) + { + await _events.PublishAsync( + new UAuthStateEventArgs( + UAuthStateEvent.ProfileChanged, + _options.StateEvents.HandlingMode, + request)); + } + + return UAuthResultMapper.From(raw); + } + + public async Task CreateUserProfileAsync(UserKey userKey, CreateProfileRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/profile/create"), request); + return UAuthResultMapper.From(raw); + } + public async Task> CreateAsync(CreateUserRequest request) { var raw = await _request.SendJsonAsync(Url("/users/create"), request); return UAuthResultMapper.FromJson(raw); } + public async Task DeleteMyProfileAsync(ProfileKey profileKey) + { + var request = new DeleteProfileRequest + { + ProfileKey = profileKey + }; + + var raw = await _request.SendJsonAsync(Url("/me/profile/delete"), request); + + if (raw.Ok) + { + await _events.PublishAsync( + new UAuthStateEventArgs( + UAuthStateEvent.ProfileChanged, + _options.StateEvents.HandlingMode, + profileKey)); + } + + return UAuthResultMapper.From(raw); + } + + public async Task DeleteUserProfileAsync(UserKey userKey, ProfileKey profileKey) + { + var request = new DeleteProfileRequest + { + ProfileKey = profileKey + }; + + var raw = await _request.SendJsonAsync( + Url($"/admin/users/{userKey.Value}/profile/delete"), + request); + + return UAuthResultMapper.From(raw); + } + public async Task> CreateAsAdminAsync(CreateUserRequest request) { var raw = await _request.SendJsonAsync(Url("/admin/users/create"), request); @@ -90,9 +148,10 @@ public async Task> DeleteUserAsync(UserKey userKey return UAuthResultMapper.FromJson(raw); } - public async Task> GetUserAsync(UserKey userKey) + public async Task> GetUserAsync(UserKey userKey, GetProfileRequest? request = null) { - var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey.Value}/profile/get")); + request = request ?? new GetProfileRequest(); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/profile/get"), request); return UAuthResultMapper.FromJson(raw); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Default/PasswordAlgorithms.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Default/PasswordAlgorithms.cs new file mode 100644 index 00000000..4514b954 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Default/PasswordAlgorithms.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public static class PasswordAlgorithms +{ + public const string Argon2 = "argon2"; + public const string Bcrypt = "bcrypt"; + public const string Legacy = "legacy"; +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialsModelBuilder.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialsModelBuilder.cs index 67415b62..5b1b3ab5 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialsModelBuilder.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialsModelBuilder.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; @@ -34,6 +35,9 @@ private static void ConfigurePasswordCredentials(ModelBuilder b) .IsRequired(); e.Property(x => x.SecretHash) + .HasConversion( + v => v.ToString(), + v => PasswordHash.Parse(v, null)) .HasMaxLength(512) .IsRequired(); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordCredentialProjection.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordCredentialProjection.cs index ae4a8dee..84726176 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordCredentialProjection.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordCredentialProjection.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; @@ -11,7 +12,7 @@ public sealed class PasswordCredentialProjection public UserKey UserKey { get; set; } - public string SecretHash { get; set; } = default!; + public PasswordHash SecretHash { get; set; } public DateTimeOffset? RevokedAt { get; set; } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs index 383bd7e0..00bbef97 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs @@ -13,7 +13,7 @@ internal sealed class EfCorePasswordCredentialStore : IPasswordCrede private readonly TDbContext _db; private readonly TenantKey _tenant; - public EfCorePasswordCredentialStore(TDbContext db, TenantContext tenant) + public EfCorePasswordCredentialStore(TDbContext db, TenantExecutionContext tenant) { _db = db; _tenant = tenant.Tenant; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStoreFactory.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStoreFactory.cs index 13a0a4a7..4efffb30 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStoreFactory.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStoreFactory.cs @@ -15,6 +15,6 @@ public EfCorePasswordCredentialStoreFactory(TDbContext db) public IPasswordCredentialStore Create(TenantKey tenant) { - return new EfCorePasswordCredentialStore(_db, new TenantContext(tenant)); + return new EfCorePasswordCredentialStore(_db, new TenantExecutionContext(tenant)); } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs index 0d1d7981..4d524d37 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs @@ -13,7 +13,7 @@ internal sealed class InMemoryPasswordCredentialStore : InMemoryTenantVersionedS protected override CredentialKey GetKey(PasswordCredential entity) => new(entity.Tenant, entity.Id); - public InMemoryPasswordCredentialStore(TenantContext tenant) : base(tenant) + public InMemoryPasswordCredentialStore(TenantExecutionContext tenant) : base(tenant) { } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStoreFactory.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStoreFactory.cs index 6258724a..fb48648d 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStoreFactory.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStoreFactory.cs @@ -10,6 +10,6 @@ public sealed class InMemoryPasswordCredentialStoreFactory : IPasswordCredential public IPasswordCredentialStore Create(TenantKey tenant) { - return _stores.GetOrAdd(tenant, t => new InMemoryPasswordCredentialStore(new TenantContext(t))); + return _stores.GetOrAdd(tenant, t => new InMemoryPasswordCredentialStore(new TenantExecutionContext(t))); } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs index 6e24cb6c..fd88eedc 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs @@ -1,8 +1,10 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Credentials.Contracts; +using System.Text.Json.Serialization; namespace CodeBeam.UltimateAuth.Credentials.Reference; @@ -13,8 +15,8 @@ public sealed class PasswordCredential : ISecretCredential, ITenantEntity, IVers public UserKey UserKey { get; init; } public CredentialType Type => CredentialType.Password; - // TODO: Add hash algorithm (PasswordHash object with hash and algorithm properties) - public string SecretHash { get; private set; } = default!; + [JsonConverter(typeof(PasswordHashJsonConverter))] + public PasswordHash SecretHash { get; private set; } public CredentialSecurityState Security { get; private set; } = CredentialSecurityState.Active(); public CredentialMetadata Metadata { get; private set; } = new CredentialMetadata(); @@ -34,7 +36,7 @@ private PasswordCredential( Guid id, TenantKey tenant, UserKey userKey, - string secretHash, + PasswordHash secretHash, CredentialSecurityState security, CredentialMetadata metadata, DateTimeOffset createdAt, @@ -80,7 +82,7 @@ public static PasswordCredential Create( Guid? id, TenantKey tenant, UserKey userKey, - string secretHash, + PasswordHash secretHash, CredentialSecurityState security, CredentialMetadata metadata, DateTimeOffset now) @@ -98,7 +100,7 @@ public static PasswordCredential Create( 0); } - public PasswordCredential ChangeSecret(string newSecretHash, DateTimeOffset now) + public PasswordCredential ChangeSecret(PasswordHash newSecretHash, DateTimeOffset now) { if (string.IsNullOrWhiteSpace(newSecretHash)) throw new UAuthValidationException("credential_secret_required"); @@ -156,7 +158,7 @@ public static PasswordCredential FromProjection( Guid id, TenantKey tenant, UserKey userKey, - string secretHash, + PasswordHash secretHash, CredentialSecurityState security, CredentialMetadata metadata, DateTimeOffset createdAt, diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ISecretCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ISecretCredential.cs index 314d8395..4eed1f27 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ISecretCredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ISecretCredential.cs @@ -1,6 +1,8 @@ -namespace CodeBeam.UltimateAuth.Credentials; +using CodeBeam.UltimateAuth.Core; + +namespace CodeBeam.UltimateAuth.Credentials; public interface ISecretCredential : ICredential { - string SecretHash { get; } + PasswordHash SecretHash { get; } } diff --git a/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryTenantVersionedStore.cs b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryTenantVersionedStore.cs index c3d3d5c3..f43531e4 100644 --- a/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryTenantVersionedStore.cs +++ b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryTenantVersionedStore.cs @@ -9,9 +9,9 @@ public abstract class InMemoryTenantVersionedStore : InMemoryVers where TEntity : class, IVersionedEntity, IEntitySnapshot, ITenantEntity where TKey : notnull, IEquatable { - private readonly TenantContext _tenant; + private readonly TenantExecutionContext _tenant; - protected InMemoryTenantVersionedStore(TenantContext tenant) + protected InMemoryTenantVersionedStore(TenantExecutionContext tenant) { _tenant = tenant; } diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs index 2ec21417..7e3b7875 100644 --- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs @@ -1,10 +1,13 @@ -using System.Security.Cryptography; -using System.Text; +using CodeBeam.UltimateAuth.Core; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Credentials.Contracts; using Konscious.Security.Cryptography; using Microsoft.Extensions.Options; +using System.Security.Cryptography; +using System.Text; +// TODO: Add rehashing support (rehash on login if options have changed, or if hash is malformed). This is important to ensure that password hashes stay up-to-date with the latest security standards and configurations. It also allows for seamless upgrades to the hashing algorithm or parameters without forcing users to reset their passwords. namespace CodeBeam.UltimateAuth.Security.Argon2; internal sealed class Argon2PasswordHasher : IUAuthPasswordHasher @@ -16,37 +19,49 @@ public Argon2PasswordHasher(IOptions options) _options = options.Value; } - public string Hash(string password) + public PasswordHash Hash(string password) { if (string.IsNullOrEmpty(password)) throw new UAuthValidationException("Password cannot be null or empty."); var salt = RandomNumberGenerator.GetBytes(_options.SaltSize); - var argon2 = CreateArgon2(password, salt); - var hash = argon2.GetBytes(_options.HashSize); - // format: - // {salt}.{hash} - return $"{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}"; + var encoded = $"{_options.Iterations}.{_options.MemorySizeKb}.{_options.Parallelism}.{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}"; + return PasswordHash.Create(PasswordAlgorithms.Argon2, encoded); } - public bool Verify(string hash, string secret) + public bool Verify(PasswordHash hash, string secret) { - if (string.IsNullOrWhiteSpace(secret) || string.IsNullOrWhiteSpace(hash)) + if (string.IsNullOrWhiteSpace(secret) || string.IsNullOrWhiteSpace(hash.Hash)) return false; - var parts = hash.Split('.'); - if (parts.Length != 2) + if (hash.Algorithm != PasswordAlgorithms.Argon2) return false; + var parts = hash.Hash.Split('.', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 5) + return false; + + if (!int.TryParse(parts[0], out var iterations) || + !int.TryParse(parts[1], out var memory) || + !int.TryParse(parts[2], out var parallelism)) + return false; + + var salt = Convert.FromBase64String(parts[3]); + var expectedHash = Convert.FromBase64String(parts[4]); + try { - var salt = Convert.FromBase64String(parts[0]); - var expectedHash = Convert.FromBase64String(parts[1]); + var argon2 = new Argon2id(Encoding.UTF8.GetBytes(secret)) + { + Salt = salt, + Iterations = iterations, + MemorySize = memory, + DegreeOfParallelism = parallelism + }; - var argon2 = CreateArgon2(secret, salt); var actualHash = argon2.GetBytes(expectedHash.Length); return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash); @@ -57,6 +72,25 @@ public bool Verify(string hash, string secret) } } + public bool NeedsRehash(PasswordHash hash) + { + if (hash.Algorithm != PasswordAlgorithms.Argon2) + return true; + + var parts = hash.Hash.Split('.', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 5) + return true; + + if (!int.TryParse(parts[0], out var iterations) || + !int.TryParse(parts[1], out var memory) || + !int.TryParse(parts[2], out var parallelism)) + return true; + + return iterations != _options.Iterations || + memory != _options.MemorySizeKb || + parallelism != _options.Parallelism; + } + private Argon2id CreateArgon2(string password, byte[] salt) { return new Argon2id(Encoding.UTF8.GetBytes(password)) diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/UltimateAuthServerBuilderArgon2Extensions.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/UltimateAuthServerBuilderArgon2Extensions.cs deleted file mode 100644 index cbce83da..00000000 --- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/UltimateAuthServerBuilderArgon2Extensions.cs +++ /dev/null @@ -1,12 +0,0 @@ -using CodeBeam.UltimateAuth.Security.Argon2; - -namespace CodeBeam.UltimateAuth.Server.Composition.Extensions; - -public static class UltimateAuthServerBuilderArgon2Extensions -{ - public static UltimateAuthServerBuilder UseArgon2(this UltimateAuthServerBuilder builder, Action? configure = null) - { - builder.Services.AddUltimateAuthArgon2(configure); - return builder; - } -} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs index ca86ff54..a1108ca4 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs @@ -12,7 +12,7 @@ internal sealed class EfCoreSessionStore : ISessionStore where TDbCo private readonly TDbContext _db; private readonly TenantKey _tenant; - public EfCoreSessionStore(TDbContext db, TenantContext tenant) + public EfCoreSessionStore(TDbContext db, TenantExecutionContext tenant) { _db = db; _tenant = tenant.Tenant; diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreFactory.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreFactory.cs index 363e3738..75b4aba7 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreFactory.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreFactory.cs @@ -15,6 +15,6 @@ public EfCoreSessionStoreFactory(TDbContext db) public ISessionStore Create(TenantKey tenant) { - return new EfCoreSessionStore(_db, new TenantContext(tenant)); + return new EfCoreSessionStore(_db, new TenantExecutionContext(tenant)); } } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs index b4be2ef5..94bbe0c1 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs @@ -11,7 +11,7 @@ internal sealed class EfCoreRefreshTokenStore : IRefreshTokenStore w private readonly TenantKey _tenant; private bool _inTransaction; - public EfCoreRefreshTokenStore(TDbContext db, TenantContext tenant) + public EfCoreRefreshTokenStore(TDbContext db, TenantExecutionContext tenant) { _db = db; _tenant = tenant.Tenant; diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStoreFactory.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStoreFactory.cs index 9cc94371..cd7e8bf9 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStoreFactory.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStoreFactory.cs @@ -15,6 +15,6 @@ public EfCoreRefreshTokenStoreFactory(TDbContext db) public IRefreshTokenStore Create(TenantKey tenant) { - return new EfCoreRefreshTokenStore(_db, new TenantContext(tenant)); + return new EfCoreRefreshTokenStore(_db, new TenantExecutionContext(tenant)); } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Domain/ProfileKey.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Domain/ProfileKey.cs new file mode 100644 index 00000000..180a1172 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Domain/ProfileKey.cs @@ -0,0 +1,66 @@ +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +[JsonConverter(typeof(ProfileKeyJsonConverter))] +public readonly record struct ProfileKey : IParsable +{ + public string Value { get; } + + private ProfileKey(string value) + { + Value = value; + } + + public static ProfileKey Default => new("default"); + + public static bool TryCreate(string? raw, out ProfileKey key) + { + if (IsValid(raw)) + { + key = new ProfileKey(Normalize(raw!)); + return true; + } + + key = default; + return false; + } + + public static ProfileKey Parse(string s, IFormatProvider? provider) + { + if (TryParse(s, provider, out var key)) + return key; + + throw new FormatException("Invalid ProfileKey."); + } + + public static bool TryParse(string? s, IFormatProvider? provider, out ProfileKey result) + { + if (IsValid(s)) + { + result = new ProfileKey(Normalize(s!)); + return true; + } + + result = default; + return false; + } + + private static bool IsValid(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + if (value.Length > 64) + return false; + + return true; + } + + private static string Normalize(string value) + => value.Trim().ToLowerInvariant(); + + public override string ToString() => Value; + + public static implicit operator string(ProfileKey key) => key.Value; +} \ No newline at end of file diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Domain/ProfileKeyJsonConverter.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Domain/ProfileKeyJsonConverter.cs new file mode 100644 index 00000000..31976379 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Domain/ProfileKeyJsonConverter.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class ProfileKeyJsonConverter : JsonConverter +{ + public override ProfileKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return default; + + if (reader.TokenType != JsonTokenType.String) + throw new JsonException("ProfileKey must be a string."); + + var value = reader.GetString(); + + if (!ProfileKey.TryCreate(value, out var key)) + throw new JsonException($"Invalid ProfileKey value: '{value}'"); + + return key; + } + + public override void Write(Utf8JsonWriter writer, ProfileKey value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Value); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceScope.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceScope.cs index ba2d8f33..92934904 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceScope.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceScope.cs @@ -5,15 +5,15 @@ public enum IdentifierExistenceScope /// /// Checks only within the same user. /// - WithinUser, + WithinUser = 0, /// /// Checks within tenant but only primary identifiers. /// - TenantPrimaryOnly, + TenantPrimaryOnly = 10, /// /// Checks within tenant regardless of primary flag. /// - TenantAny + TenantAny = 20 } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/MfaMethod.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/MfaMethod.cs index f207ae2c..1c86080e 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/MfaMethod.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/MfaMethod.cs @@ -2,8 +2,8 @@ public enum MfaMethod { - Totp = 10, - Sms = 20, - Email = 30, - Passkey = 40 + Totp = 0, + Sms = 10, + Email = 20, + Passkey = 30 } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierType.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierType.cs index 7267c203..3db0c4c1 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierType.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierType.cs @@ -2,8 +2,8 @@ public enum UserIdentifierType { - Username, - Email, - Phone, - Custom + Username = 0, + Email = 10, + Phone = 20, + Custom = 100 } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs index 43e1fcf8..596f089d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs @@ -7,4 +7,5 @@ public sealed record UserQuery : PageRequest public string? Search { get; set; } public UserStatus? Status { get; set; } public bool IncludeDeleted { get; set; } + public ProfileKey? ProfileKey { get; set; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserView.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserView.cs index b245402d..7db9293b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserView.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserView.cs @@ -12,6 +12,7 @@ public sealed record UserView public string? PrimaryEmail { get; init; } public string? PrimaryPhone { get; init; } + public ProfileKey ProfileKey { get; set; } public string? FirstName { get; init; } public string? LastName { get; init; } public string? DisplayName { get; init; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateProfileRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateProfileRequest.cs new file mode 100644 index 00000000..b1ed0059 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateProfileRequest.cs @@ -0,0 +1,22 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class CreateProfileRequest +{ + public required ProfileKey ProfileKey { get; init; } + + public ProfileKey? CloneFrom { get; init; } + + public string? FirstName { get; init; } + public string? LastName { get; init; } + public string? DisplayName { get; init; } + + public DateOnly? BirthDate { get; init; } + public string? Gender { get; init; } + public string? Bio { get; init; } + + public string? Language { get; init; } + public string? TimeZone { get; init; } + public string? Culture { get; init; } + + public Dictionary? Metadata { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteProfileRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteProfileRequest.cs new file mode 100644 index 00000000..ca822e34 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteProfileRequest.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class DeleteProfileRequest +{ + public required ProfileKey ProfileKey { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/GetProfileRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/GetProfileRequest.cs new file mode 100644 index 00000000..5d730eab --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/GetProfileRequest.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class GetProfileRequest +{ + public ProfileKey? ProfileKey { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateProfileRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateProfileRequest.cs index 018aac82..73093aa2 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateProfileRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateProfileRequest.cs @@ -2,6 +2,7 @@ public sealed record UpdateProfileRequest { + public ProfileKey? ProfileKey { get; set; } public string? FirstName { get; init; } public string? LastName { get; init; } public string? DisplayName { get; init; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUsersModelBuilder.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUsersModelBuilder.cs index edbf278c..d5d6763d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUsersModelBuilder.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUsersModelBuilder.cs @@ -1,6 +1,8 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; using Microsoft.EntityFrameworkCore; namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; @@ -108,6 +110,11 @@ private static void ConfigureProfiles(ModelBuilder b) .HasMaxLength(128) .IsRequired(); + e.Property(x => x.ProfileKey) + .HasConversion(v => v.Value, v => ProfileKey.Parse(v, null)) + .HasMaxLength(64) + .IsRequired(); + e.Property(x => x.Metadata) .HasConversion(new NullableJsonValueConverter>()) .Metadata.SetValueComparer(JsonValueComparers.Create>()); @@ -116,7 +123,7 @@ private static void ConfigureProfiles(ModelBuilder b) e.Property(x => x.UpdatedAt).HasNullableUtcDateTimeOffsetConverter(); e.Property(x => x.DeletedAt).HasNullableUtcDateTimeOffsetConverter(); - e.HasIndex(x => new { x.Tenant, x.UserKey }); + e.HasIndex(x => new { x.Tenant, x.UserKey, x.ProfileKey }).IsUnique(); }); } } \ No newline at end of file diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserProfileMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserProfileMapper.cs index aaa2addb..8063234a 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserProfileMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserProfileMapper.cs @@ -10,6 +10,7 @@ public static UserProfile ToDomain(this UserProfileProjection p) p.Id, p.Tenant, p.UserKey, + p.ProfileKey, p.FirstName, p.LastName, p.DisplayName, @@ -33,6 +34,7 @@ public static UserProfileProjection ToProjection(this UserProfile d) Id = d.Id, Tenant = d.Tenant, UserKey = d.UserKey, + ProfileKey = d.ProfileKey, FirstName = d.FirstName, LastName = d.LastName, DisplayName = d.DisplayName, @@ -66,7 +68,7 @@ public static void UpdateProjection(this UserProfile source, UserProfileProjecti target.Culture = source.Culture; // Version store-owned - // Id / Tenant / UserKey / CreatedAt immutable + // Id / Tenant / UserKey / ProfileKey / CreatedAt immutable } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserProfileProjection.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserProfileProjection.cs index 90dfed20..0698c205 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserProfileProjection.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserProfileProjection.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; @@ -11,6 +12,8 @@ public sealed class UserProfileProjection public UserKey UserKey { get; set; } = default!; + public ProfileKey ProfileKey { get; set; } = ProfileKey.Default; + public string? FirstName { get; set; } public string? LastName { get; set; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EFCoreUserProfileStoreFactory.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EFCoreUserProfileStoreFactory.cs index e3c4147e..68669465 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EFCoreUserProfileStoreFactory.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EFCoreUserProfileStoreFactory.cs @@ -15,6 +15,6 @@ public EfCoreUserProfileStoreFactory(TDbContext db) public IUserProfileStore Create(TenantKey tenant) { - return new EfCoreUserProfileStore(_db, new TenantContext(tenant)); + return new EfCoreUserProfileStore(_db, new TenantExecutionContext(tenant)); } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs index e52bb943..14e4de4f 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs @@ -13,7 +13,7 @@ internal sealed class EfCoreUserIdentifierStore : IUserIdentifierSto private readonly TDbContext _db; private readonly TenantKey _tenant; - public EfCoreUserIdentifierStore(TDbContext db, TenantContext tenant) + public EfCoreUserIdentifierStore(TDbContext db, TenantExecutionContext tenant) { _db = db; _tenant = tenant.Tenant; diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStoreFactory.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStoreFactory.cs index 2d343b42..cd0be9fc 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStoreFactory.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStoreFactory.cs @@ -15,6 +15,6 @@ public EfCoreUserIdentifierStoreFactory(TDbContext db) public IUserIdentifierStore Create(TenantKey tenant) { - return new EfCoreUserIdentifierStore(_db, new TenantContext(tenant)); + return new EfCoreUserIdentifierStore(_db, new TenantExecutionContext(tenant)); } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs index 63c8ef35..9994d412 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs @@ -11,7 +11,7 @@ internal sealed class EfCoreUserLifecycleStore : IUserLifecycleStore private readonly TDbContext _db; private readonly TenantKey _tenant; - public EfCoreUserLifecycleStore(TDbContext db, TenantContext tenant) + public EfCoreUserLifecycleStore(TDbContext db, TenantExecutionContext tenant) { _db = db; _tenant = tenant.Tenant; diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStoreFactory.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStoreFactory.cs index 7e5c4d44..38fb3af7 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStoreFactory.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStoreFactory.cs @@ -15,6 +15,6 @@ public EfCoreUserLifecycleStoreFactory(TDbContext db) public IUserLifecycleStore Create(TenantKey tenant) { - return new EfCoreUserLifecycleStore(_db, new TenantContext(tenant)); + return new EfCoreUserLifecycleStore(_db, new TenantExecutionContext(tenant)); } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs index 9623dc92..dbbe32fd 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs @@ -2,8 +2,10 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Internal; namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; @@ -12,7 +14,7 @@ internal sealed class EfCoreUserProfileStore : IUserProfileStore whe private readonly TDbContext _db; private readonly TenantKey _tenant; - public EfCoreUserProfileStore(TDbContext db, TenantContext tenant) + public EfCoreUserProfileStore(TDbContext db, TenantExecutionContext tenant) { _db = db; _tenant = tenant.Tenant; @@ -28,7 +30,8 @@ public EfCoreUserProfileStore(TDbContext db, TenantContext tenant) .AsNoTracking() .SingleOrDefaultAsync(x => x.Tenant == _tenant && - x.UserKey == key.UserKey, + x.UserKey == key.UserKey && + x.ProfileKey == key.ProfileKey.Value, ct); return projection?.ToDomain(); @@ -41,7 +44,8 @@ public async Task ExistsAsync(UserProfileKey key, CancellationToken ct = d return await DbSet .AnyAsync(x => x.Tenant == _tenant && - x.UserKey == key.UserKey, + x.UserKey == key.UserKey && + x.ProfileKey == key.ProfileKey.Value, ct); } @@ -54,6 +58,16 @@ public async Task AddAsync(UserProfile entity, CancellationToken ct = default) if (entity.Version != 0) throw new InvalidOperationException("New profile must have version 0."); + var exists = await DbSet + .AnyAsync(x => + x.Tenant == entity.Tenant.Value && + x.UserKey == entity.UserKey.Value && + x.ProfileKey == entity.ProfileKey.Value, + ct); + + if (exists) + throw new UAuthConflictException("profile_already_exists"); + DbSet.Add(projection); await _db.SaveChangesAsync(ct); @@ -66,7 +80,8 @@ public async Task SaveAsync(UserProfile entity, long expectedVersion, Cancellati var existing = await DbSet .SingleOrDefaultAsync(x => x.Tenant == _tenant && - x.UserKey == entity.UserKey, + x.UserKey == entity.UserKey && + x.ProfileKey == entity.ProfileKey.Value, ct); if (existing is null) @@ -88,7 +103,8 @@ public async Task DeleteAsync(UserProfileKey key, long expectedVersion, DeleteMo var projection = await DbSet .SingleOrDefaultAsync(x => x.Tenant == _tenant && - x.UserKey == key.UserKey, + x.UserKey == key.UserKey && + x.ProfileKey == key.ProfileKey.Value, ct); if (projection is null) @@ -120,6 +136,11 @@ public async Task> QueryAsync(UserProfileQuery query, C .AsNoTracking() .Where(x => x.Tenant == _tenant); + if (query.ProfileKey != null) + { + baseQuery = baseQuery.Where(x => x.ProfileKey == query.ProfileKey.Value); + } + if (!query.IncludeDeleted) baseQuery = baseQuery.Where(x => x.DeletedAt == null); @@ -164,7 +185,7 @@ public async Task> QueryAsync(UserProfileQuery query, C query.Descending); } - public async Task> GetByUsersAsync(IReadOnlyList userKeys, CancellationToken ct = default) + public async Task> GetByUsersAsync(IReadOnlyList userKeys, ProfileKey profileKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -172,9 +193,22 @@ public async Task> GetByUsersAsync(IReadOnlyList x.Tenant == _tenant) .Where(x => userKeys.Contains(x.UserKey)) + .Where(x => x.ProfileKey == profileKey.Value) .Where(x => x.DeletedAt == null) .ToListAsync(ct); return projections.Select(x => x.ToDomain()).ToList(); } + + public async Task> GetAllProfilesByUserAsync(UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projections = await DbSet + .AsNoTracking() + .Where(x => x.Tenant == _tenant) + .Where(x => x.UserKey == userKey) + .ToListAsync(ct); + return projections.Select(x => x.ToDomain()).ToList(); + } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs index cd624dbc..698836dd 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs @@ -13,7 +13,7 @@ public sealed class InMemoryUserIdentifierStore : InMemoryTenantVersionedStore entity.Id; private readonly object _primaryLock = new(); - public InMemoryUserIdentifierStore(TenantContext tenant) : base(tenant) + public InMemoryUserIdentifierStore(TenantExecutionContext tenant) : base(tenant) { } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStoreFactory.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStoreFactory.cs index 828fcc51..c6145e2a 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStoreFactory.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStoreFactory.cs @@ -20,7 +20,7 @@ public IUserIdentifierStore Create(TenantKey tenant) return _stores.GetOrAdd(tenant, t => { Console.WriteLine("New Store Added"); - var tenantContext = new TenantContext(tenant); + var tenantContext = new TenantExecutionContext(tenant); return ActivatorUtilities.CreateInstance(_provider, tenantContext); }); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs index 4546acfc..0cd4633b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs @@ -10,7 +10,7 @@ public sealed class InMemoryUserLifecycleStore : InMemoryTenantVersionedStore new(entity.Tenant, entity.UserKey); - public InMemoryUserLifecycleStore(TenantContext tenant) : base(tenant) + public InMemoryUserLifecycleStore(TenantExecutionContext tenant) : base(tenant) { } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStoreFactory.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStoreFactory.cs index f19359f7..6794d694 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStoreFactory.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStoreFactory.cs @@ -19,7 +19,7 @@ public IUserLifecycleStore Create(TenantKey tenant) { return _stores.GetOrAdd(tenant, t => { - var tenantContext = new TenantContext(tenant); + var tenantContext = new TenantExecutionContext(tenant); return ActivatorUtilities.CreateInstance(_provider, tenantContext); }); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs index 38195576..129d7e92 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs @@ -1,7 +1,9 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.InMemory; +using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; namespace CodeBeam.UltimateAuth.Users.InMemory; @@ -9,12 +11,23 @@ namespace CodeBeam.UltimateAuth.Users.InMemory; public sealed class InMemoryUserProfileStore : InMemoryTenantVersionedStore, IUserProfileStore { protected override UserProfileKey GetKey(UserProfile entity) - => new(entity.Tenant, entity.UserKey); + => new(entity.Tenant, entity.UserKey, entity.ProfileKey); - public InMemoryUserProfileStore(TenantContext tenant) : base(tenant) + public InMemoryUserProfileStore(TenantExecutionContext tenant) : base(tenant) { } + protected override void BeforeAdd(UserProfile entity) + { + var exists = TenantValues() + .Any(x => + x.UserKey == entity.UserKey && + x.ProfileKey == entity.ProfileKey); + + if (exists) + throw new UAuthConflictException("profile_already_exists"); + } + public Task> QueryAsync(UserProfileQuery query, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -22,6 +35,11 @@ public Task> QueryAsync(UserProfileQuery query, Cancell var normalized = query.Normalize(); var baseQuery = TenantValues().AsQueryable(); + if (query.ProfileKey != null) + { + baseQuery = baseQuery.Where(x => x.ProfileKey == query.ProfileKey); + } + if (!query.IncludeDeleted) baseQuery = baseQuery.Where(x => !x.IsDeleted); @@ -69,19 +87,36 @@ public Task> QueryAsync(UserProfileQuery query, Cancell query.Descending)); } - public Task> GetByUsersAsync(IReadOnlyList userKeys, CancellationToken ct = default) + public Task> GetByUsersAsync(IReadOnlyList userKeys, ProfileKey profileKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); var set = userKeys.ToHashSet(); - var result = TenantValues() + var query = TenantValues() .Where(x => set.Contains(x.UserKey)) - .Where(x => !x.IsDeleted) + .Where(x => x.ProfileKey == profileKey) + .Where(x => !x.IsDeleted); + + var result = query .Select(x => x.Snapshot()) .ToList() .AsReadOnly(); return Task.FromResult>(result); } + + public Task> GetAllProfilesByUserAsync(UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var query = TenantValues() + .Where(x => x.UserKey == userKey) + .Where(x => !x.IsDeleted); + var result = query + .Select(x => x.Snapshot()) + .ToList() + .AsReadOnly(); + return Task.FromResult>(result); + } } \ No newline at end of file diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStoreFactory.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStoreFactory.cs index b6f49bd4..0502c922 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStoreFactory.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStoreFactory.cs @@ -19,7 +19,7 @@ public IUserProfileStore Create(TenantKey tenant) { return _stores.GetOrAdd(tenant, t => { - var tenantContext = new TenantContext(t); + var tenantContext = new TenantExecutionContext(t); return ActivatorUtilities.CreateInstance(_provider, tenantContext); }); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs index 3c3835e6..6e122100 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs @@ -1,8 +1,10 @@ using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; public sealed record UserProfileQuery : PageRequest { public bool IncludeDeleted { get; init; } + public ProfileKey? ProfileKey { get; set; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs index 9bc18905..5830a22d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs @@ -1,10 +1,10 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; -// TODO: Multi profile (e.g., public profiles, private profiles, profiles per application, etc. with ProfileKey) public sealed class UserProfile : ITenantEntity, IVersionedEntity, ISoftDeletable, IEntitySnapshot { private UserProfile() { } @@ -13,6 +13,7 @@ private UserProfile() { } public TenantKey Tenant { get; private set; } public UserKey UserKey { get; init; } = default!; + public ProfileKey ProfileKey { get; set; } = ProfileKey.Default; public string? FirstName { get; private set; } public string? LastName { get; private set; } @@ -44,6 +45,7 @@ public UserProfile Snapshot() Id = Id, Tenant = Tenant, UserKey = UserKey, + ProfileKey = ProfileKey, FirstName = FirstName, LastName = LastName, DisplayName = DisplayName, @@ -65,6 +67,7 @@ public static UserProfile Create( Guid? id, TenantKey tenant, UserKey userKey, + ProfileKey? profileKey, DateTimeOffset createdAt, string? firstName = null, string? lastName = null, @@ -81,6 +84,7 @@ public static UserProfile Create( Id = id ?? Guid.NewGuid(), Tenant = tenant, UserKey = userKey, + ProfileKey = profileKey ?? ProfileKey.Default, FirstName = firstName, LastName = lastName, DisplayName = displayName, @@ -169,6 +173,7 @@ public static UserProfile FromProjection( Guid id, TenantKey tenant, UserKey userKey, + ProfileKey profileKey, string? firstName, string? lastName, string? displayName, @@ -189,6 +194,7 @@ public static UserProfile FromProjection( Id = id, Tenant = tenant, UserKey = userKey, + ProfileKey = profileKey, FirstName = firstName, LastName = lastName, DisplayName = displayName, @@ -205,4 +211,47 @@ public static UserProfile FromProjection( Version = version }; } + + public UserProfile CloneTo( + Guid? newId, + ProfileKey newProfileKey, + DateTimeOffset now, + Action? mutate = null) + { + if (IsDeleted) + throw new InvalidOperationException("cannot_clone_deleted_profile"); + + var clone = new UserProfile + { + Id = newId ?? Guid.NewGuid(), + Tenant = Tenant, + UserKey = UserKey, + ProfileKey = newProfileKey, + + FirstName = FirstName, + LastName = LastName, + DisplayName = DisplayName, + + BirthDate = BirthDate, + Gender = Gender, + Bio = Bio, + + Language = Language, + TimeZone = TimeZone, + Culture = Culture, + + Metadata = Metadata is null + ? null + : new Dictionary(Metadata), + + CreatedAt = now, + UpdatedAt = null, + DeletedAt = null, + Version = 0 + }; + + mutate?.Invoke(clone); + + return clone; + } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfileKey.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfileKey.cs index c197d94f..2aa643b6 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfileKey.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfileKey.cs @@ -1,8 +1,10 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; public readonly record struct UserProfileKey( TenantKey Tenant, - UserKey UserKey); + UserKey UserKey, + ProfileKey ProfileKey); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs index b48a4ecf..7ec8f92d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs @@ -120,13 +120,15 @@ public async Task GetMeAsync(HttpContext ctx) if (!flow.IsAuthenticated) return Results.Unauthorized(); + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + var accessContext = await _accessContextFactory.CreateAsync( authFlow: flow, action: UAuthActions.UserProfiles.GetSelf, resource: "users", resourceId: flow?.UserKey?.Value); - var profile = await _users.GetMeAsync(accessContext, ctx.RequestAborted); + var profile = await _users.GetMeAsync(accessContext, request?.ProfileKey, ctx.RequestAborted); return Results.Ok(profile); } @@ -136,13 +138,15 @@ public async Task GetUserAsync(UserKey userKey, HttpContext ctx) if (!flow.IsAuthenticated) return Results.Unauthorized(); + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + var accessContext = await _accessContextFactory.CreateAsync( authFlow: flow, action: UAuthActions.UserProfiles.GetAdmin, resource: "users", resourceId: userKey.Value); - var profile = await _users.GetUserProfileAsync(accessContext, ctx.RequestAborted); + var profile = await _users.GetUserProfileAsync(accessContext, request?.ProfileKey, ctx.RequestAborted); return Results.Ok(profile); } @@ -220,6 +224,78 @@ public async Task DeleteAsync(UserKey userKey, HttpContext ctx) return Results.Ok(); } + public async Task CreateProfileSelfAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserProfiles.CreateSelf, + resource: "users", + resourceId: flow.UserKey!.Value); + + await _users.CreateProfileAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task CreateProfileAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserProfiles.CreateAdmin, + resource: "users", + resourceId: userKey.Value); + + await _users.CreateProfileAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task DeleteProfileSelfAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserProfiles.DeleteSelf, + resource: "users", + resourceId: flow.UserKey!.Value); + + await _users.DeleteProfileAsync(accessContext, request.ProfileKey, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task DeleteProfileAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserProfiles.DeleteAdmin, + resource: "users", + resourceId: userKey.Value); + + await _users.DeleteProfileAsync(accessContext, request.ProfileKey, ctx.RequestAborted); + return Results.Ok(); + } + public async Task GetMyIdentifiersAsync(HttpContext ctx) { var flow = _authFlow.Current; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs index 72cbe134..f924f3aa 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs @@ -13,10 +13,10 @@ public UserProfileSnapshotProvider(IUserProfileStoreFactory storeFactory) _storeFactory = storeFactory; } - public async Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + public async Task GetAsync(TenantKey tenant, UserKey userKey, ProfileKey profileKey, CancellationToken ct = default) { var store = _storeFactory.Create(tenant); - var profile = await store.GetAsync(new UserProfileKey(tenant, userKey), ct); + var profile = await store.GetAsync(new UserProfileKey(tenant, userKey, profileKey), ct); if (profile is null || profile.IsDeleted) return null; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs index e7a95f69..6f3cdf43 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs @@ -8,6 +8,7 @@ public static UserView ToDto(UserProfile profile) => new() { UserKey = profile.UserKey, + ProfileKey = profile.ProfileKey, FirstName = profile.FirstName, LastName = profile.LastName, DisplayName = profile.DisplayName, diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs index 5c9157f1..035eead1 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs @@ -5,15 +5,17 @@ namespace CodeBeam.UltimateAuth.Users.Reference; public interface IUserApplicationService { - Task GetMeAsync(AccessContext context, CancellationToken ct = default); - Task GetUserProfileAsync(AccessContext context, CancellationToken ct = default); + Task GetMeAsync(AccessContext context, ProfileKey? profileKey = null, CancellationToken ct = default); + Task GetUserProfileAsync(AccessContext context, ProfileKey? profileKey = null, CancellationToken ct = default); Task> QueryUsersAsync(AccessContext context, UserQuery query, CancellationToken ct = default); Task CreateUserAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default); Task ChangeUserStatusAsync(AccessContext context, object request, CancellationToken ct = default); + Task CreateProfileAsync(AccessContext context, CreateProfileRequest request, CancellationToken ct = default); Task UpdateUserProfileAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default); + Task DeleteProfileAsync(AccessContext context, ProfileKey profileKey, CancellationToken ct = default); Task> GetIdentifiersByUserAsync(AccessContext context, UserIdentifierQuery query, CancellationToken ct = default); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index aa354974..27ee484a 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -75,6 +75,7 @@ await profileStore.AddAsync( Guid.NewGuid(), context.ResourceTenant, userKey, + ProfileKey.Default, now, firstName: request.FirstName, lastName: request.LastName, @@ -195,15 +196,16 @@ public async Task DeleteMeAsync(AccessContext context, CancellationToken ct = de var profileStore = _profileStoreFactory.Create(context.ResourceTenant); var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); - var profileKey = new UserProfileKey(context.ResourceTenant, userKey); - var profile = await profileStore.GetAsync(profileKey, innerCt); await lifecycleStore.DeleteAsync(lifecycleKey, lifecycle.Version, DeleteMode.Soft, now, innerCt); await identifierStore.DeleteByUserAsync(userKey, DeleteMode.Soft, now, innerCt); - if (profile is not null) + var profiles = await profileStore.GetAllProfilesByUserAsync(userKey, innerCt); + + foreach (var profile in profiles) { - await profileStore.DeleteAsync(profileKey, profile.Version, DeleteMode.Soft, now, innerCt); + var key = new UserProfileKey(context.ResourceTenant, userKey, profile.ProfileKey); + await profileStore.DeleteAsync(key, profile.Version, DeleteMode.Soft, now, innerCt); } foreach (var integration in _integrations) @@ -233,14 +235,15 @@ public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest reque var profileStore = _profileStoreFactory.Create(context.ResourceTenant); var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); - var profileKey = new UserProfileKey(context.ResourceTenant, targetUserKey); - var profile = await profileStore.GetAsync(profileKey, innerCt); await lifecycleStore.DeleteAsync(userLifecycleKey, lifecycle.Version, request.Mode, now, innerCt); await identifierStore.DeleteByUserAsync(targetUserKey, request.Mode, now, innerCt); - if (profile is not null) + var profiles = await profileStore.GetAllProfilesByUserAsync(targetUserKey, innerCt); + + foreach (var profile in profiles) { - await profileStore.DeleteAsync(profileKey, profile.Version, request.Mode, now, innerCt); + var key = new UserProfileKey(context.ResourceTenant, profile.UserKey, profile.ProfileKey); + await profileStore.DeleteAsync(key, profile.Version, DeleteMode.Soft, now, innerCt); } foreach (var integration in _integrations) @@ -257,31 +260,99 @@ public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest reque #region User Profile - public async Task GetMeAsync(AccessContext context, CancellationToken ct = default) + public async Task GetMeAsync(AccessContext context, ProfileKey? profileKey = null, CancellationToken ct = default) { var command = new AccessCommand(async innerCt => { + var effectiveProfileKey = profileKey ?? ProfileKey.Default; + if (context.ActorUserKey is null) throw new UnauthorizedAccessException(); - return await BuildUserViewAsync(context.ResourceTenant, context.ActorUserKey.Value, innerCt); + if (!_options.UserProfile.EnableMultiProfile && effectiveProfileKey != ProfileKey.Default) + throw new UAuthConflictException("multi_profile_disabled"); + + return await BuildUserViewAsync(context.ResourceTenant, context.ActorUserKey.Value, effectiveProfileKey, innerCt); }); return await _accessOrchestrator.ExecuteAsync(context, command, ct); } - public async Task GetUserProfileAsync(AccessContext context, CancellationToken ct = default) + public async Task GetUserProfileAsync(AccessContext context, ProfileKey? profileKey = null, CancellationToken ct = default) { var command = new AccessCommand(async innerCt => { + var effectiveProfileKey = profileKey ?? ProfileKey.Default; + + if (!_options.UserProfile.EnableMultiProfile && effectiveProfileKey != ProfileKey.Default) + throw new UAuthConflictException("multi_profile_disabled"); + var targetUserKey = context.GetTargetUserKey(); - return await BuildUserViewAsync(context.ResourceTenant, targetUserKey, innerCt); + return await BuildUserViewAsync(context.ResourceTenant, targetUserKey, effectiveProfileKey, innerCt); }); return await _accessOrchestrator.ExecuteAsync(context, command, ct); } + public async Task CreateProfileAsync(AccessContext context, CreateProfileRequest request, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var tenant = context.ResourceTenant; + var userKey = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + var profileKey = request.ProfileKey; + + if (!_options.UserProfile.EnableMultiProfile) + throw new UAuthConflictException("multi_profile_disabled"); + + if (profileKey == ProfileKey.Default) + throw new UAuthConflictException("default_profile_already_exists"); + + var store = _profileStoreFactory.Create(tenant); + + var exists = await store.ExistsAsync(new UserProfileKey(tenant, userKey, profileKey), innerCt); + + if (exists) + throw new UAuthConflictException("profile_already_exists"); + + UserProfile profile; + if (request.CloneFrom is ProfileKey cloneFromKey) + { + var source = await store.GetAsync(new UserProfileKey(tenant, userKey, cloneFromKey), innerCt); + + if (source == null) + throw new UAuthNotFoundException("source_profile_not_found"); + + profile = source.CloneTo(Guid.NewGuid(), profileKey, now); + } + else + { + profile = UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + profileKey, + now, + firstName: request.FirstName, + lastName: request.LastName, + displayName: request.DisplayName, + birthDate: request.BirthDate, + gender: request.Gender, + bio: request.Bio, + language: request.Language, + timezone: request.TimeZone, + culture: request.Culture); + } + + await store.AddAsync(profile, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + public async Task UpdateUserProfileAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default) { var command = new AccessCommand(async innerCt => @@ -290,13 +361,17 @@ public async Task UpdateUserProfileAsync(AccessContext context, UpdateProfileReq var userKey = context.GetTargetUserKey(); var now = _clock.UtcNow; - var key = new UserProfileKey(tenant, userKey); + var profileKey = request.ProfileKey ?? ProfileKey.Default; + var key = new UserProfileKey(tenant, userKey, profileKey); var profileStore = _profileStoreFactory.Create(tenant); var profile = await profileStore.GetAsync(key, innerCt); if (profile is null) throw new UAuthNotFoundException(); + if (!_options.UserProfile.EnableMultiProfile && profileKey != ProfileKey.Default) + throw new UAuthConflictException("multi_profile_disabled"); + var expectedVersion = profile.Version; profile @@ -311,6 +386,39 @@ public async Task UpdateUserProfileAsync(AccessContext context, UpdateProfileReq await _accessOrchestrator.ExecuteAsync(context, command, ct); } + public async Task DeleteProfileAsync(AccessContext context, ProfileKey profileKey, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var tenant = context.ResourceTenant; + var userKey = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + if (!_options.UserProfile.EnableMultiProfile) + throw new UAuthConflictException("multi_profile_disabled"); + + if (profileKey == ProfileKey.Default) + throw new UAuthConflictException("cannot_delete_default_profile"); + + var store = _profileStoreFactory.Create(tenant); + + var key = new UserProfileKey(tenant, userKey, profileKey); + var profile = await store.GetAsync(key, innerCt); + + if (profile is null || profile.IsDeleted) + throw new UAuthNotFoundException("user_profile_not_found"); + + var profiles = await store.GetAllProfilesByUserAsync(userKey, innerCt); + + if (profiles.Count <= 1) + throw new UAuthConflictException("cannot_delete_last_profile"); + + await store.DeleteAsync(key, profile.Version, DeleteMode.Soft, now, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + #endregion @@ -658,13 +766,15 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde #region Helpers - private async Task BuildUserViewAsync(TenantKey tenant, UserKey userKey, CancellationToken ct) + private async Task BuildUserViewAsync(TenantKey tenant, UserKey userKey, ProfileKey? profileKey, CancellationToken ct) { + var effectiveProfileKey = profileKey ?? ProfileKey.Default; + var lifecycleStore = _lifecycleStoreFactory.Create(tenant); var identifierStore = _identifierStoreFactory.Create(tenant); var profileStore = _profileStoreFactory.Create(tenant); var lifecycle = await lifecycleStore.GetAsync(new UserLifecycleKey(tenant, userKey)); - var profile = await profileStore.GetAsync(new UserProfileKey(tenant, userKey), ct); + var profile = await profileStore.GetAsync(new UserProfileKey(tenant, userKey, effectiveProfileKey), ct); if (lifecycle is null || lifecycle.IsDeleted) throw new UAuthNotFoundException("user_not_found"); @@ -751,6 +861,7 @@ public async Task> QueryUsersAsync(AccessContext contex var command = new AccessCommand>(async innerCt => { query ??= new UserQuery(); + var effectiveProfileKey = query.ProfileKey ?? ProfileKey.Default; var lifecycleQuery = new UserLifecycleQuery { @@ -778,7 +889,7 @@ public async Task> QueryUsersAsync(AccessContext contex var userKeys = lifecycles.Select(x => x.UserKey).ToList(); var profileStore = _profileStoreFactory.Create(context.ResourceTenant); var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); - var profiles = await profileStore.GetByUsersAsync(userKeys, innerCt); + var profiles = await profileStore.GetByUsersAsync(userKeys, effectiveProfileKey, innerCt); var identifiers = await identifierStore.GetByUsersAsync(userKeys, innerCt); var profileMap = profiles.ToDictionary(x => x.UserKey); var identifierGroups = identifiers.GroupBy(x => x.UserKey).ToDictionary(x => x.Key, x => x.ToList()); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs index 5d63cb60..0b6aff75 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs @@ -1,11 +1,13 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; public interface IUserProfileStore : IVersionedStore { Task> QueryAsync(UserProfileQuery query, CancellationToken ct = default); - Task> GetByUsersAsync(IReadOnlyList userKeys, CancellationToken ct = default); + Task> GetByUsersAsync(IReadOnlyList userKeys, ProfileKey profileKey, CancellationToken ct = default); + Task> GetAllProfilesByUserAsync(UserKey userKey, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserProfileSnapshotProvider.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserProfileSnapshotProvider.cs index 38b2f00c..8a1789b7 100644 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserProfileSnapshotProvider.cs +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserProfileSnapshotProvider.cs @@ -6,5 +6,5 @@ namespace CodeBeam.UltimateAuth.Users; public interface IUserProfileSnapshotProvider { - Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); + Task GetAsync(TenantKey tenant, UserKey userKey, ProfileKey profileKey, CancellationToken ct = default); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs index a06b2b12..5d8a86af 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs @@ -1,4 +1,5 @@ -using FluentAssertions; +using CodeBeam.UltimateAuth.Users.Contracts; +using FluentAssertions; using Microsoft.AspNetCore.Mvc.Testing; using System.Net; using System.Net.Http.Json; @@ -83,14 +84,14 @@ public async Task Authenticated_User_Should_Access_Me_Endpoint() var cookie = loginResponse.Headers.GetValues("Set-Cookie").First(); _client.DefaultRequestHeaders.Add("Cookie", cookie); - var response = await _client.PostAsync("/auth/me/get", null); + var response = await _client.PostAsJsonAsync("/auth/me/profile/get", new GetProfileRequest() { ProfileKey = null }); response.StatusCode.Should().Be(HttpStatusCode.OK); } [Fact] public async Task Anonymous_Should_Not_Access_Me() { - var response = await _client.PostAsync("/auth/me/get", null); + var response = await _client.PostAsync("/auth/me/profile/get", null); response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } } \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs new file mode 100644 index 00000000..dfd73b27 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs @@ -0,0 +1,200 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using System.Net; +using System.Net.Http.Json; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public class RefreshTests : IClassFixture +{ + private readonly HttpClient _client; + + public RefreshTests(AuthServerFactory factory) + { + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + _client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); + _client.DefaultRequestHeaders.Add("X-UDID", "test-device-1234567890123456"); + } + + [Fact] + public async Task Refresh_PureOpaque_Should_Touch_Session() + { + await LoginAsync("BlazorServer"); + var response = await RefreshAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + response.Headers.TryGetValues("Set-Cookie", out var cookies).Should().BeTrue(); + } + + [Fact] + public async Task Refresh_PureOpaque_Invalid_Should_Return_Unauthorized() + { + SetClientProfile("BlazorServer"); + var response = await RefreshAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_Hybrid_Should_Rotate_Tokens() + { + await LoginAsync("BlazorWasm"); + + var response = await RefreshAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + response.Headers.TryGetValues("Set-Cookie", out var cookies).Should().BeTrue(); + cookies.Should().NotBeEmpty(); + } + + [Fact] + public async Task Refresh_Hybrid_Should_Fail_On_Reuse() + { + await LoginAsync("BlazorWasm"); + + var first = await RefreshAsync(); + first.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var second = await RefreshAsync(); + + second.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_PureOpaque_Should_Not_Touch_Immediately() + { + await LoginAsync("BlazorServer"); + + var first = await RefreshAsync(); + first.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var second = await RefreshAsync(); + + second.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Refresh_Hybrid_Should_Fail_When_Session_Mismatch() + { + var factory = new AuthServerFactory(); + + var client1 = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + var client2 = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + var cookie1 = await LoginAsync(client1, "BlazorWasm", "device-1-1234567890123456"); + var cookie2 = await LoginAsync(client2, "BlazorWasm", "device-2-1234567890123456"); + + cookie1.Should().NotBeNullOrWhiteSpace(); + cookie2.Should().NotBeNullOrWhiteSpace(); + cookie1.Should().NotBe(cookie2); + + client2.DefaultRequestHeaders.Remove("Cookie"); + client2.DefaultRequestHeaders.Add("Cookie", cookie1); + var response = await client2.PostAsync("/auth/refresh", null); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_Hybrid_Should_Fail_Without_RefreshToken() + { + await LoginAsync("BlazorWasm"); + var cookies = _client.DefaultRequestHeaders.GetValues("Cookie").First(); + var onlySession = string.Join("; ", cookies.Split("; ").Where(x => x.StartsWith("uas="))); + + _client.DefaultRequestHeaders.Remove("Cookie"); + _client.DefaultRequestHeaders.Add("Cookie", onlySession); + + var response = await _client.PostAsync("/auth/refresh", null); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + + private async Task LoginAsync(string profile) + { + SetClientProfile(profile); + + var response = await _client.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + response.StatusCode.Should().Be(HttpStatusCode.Found); + + var cookies = response.Headers.GetValues("Set-Cookie") + .Select(x => x.Split(';')[0]); + + var cookieHeader = string.Join("; ", cookies); + + _client.DefaultRequestHeaders.Remove("Cookie"); + _client.DefaultRequestHeaders.Add("Cookie", cookieHeader); + } + + private async Task LoginAsync(HttpClient client, string profile, string udid = "test-device-1234567890123456") + { + client.DefaultRequestHeaders.Remove("Origin"); + client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); + + client.DefaultRequestHeaders.Remove("X-UDID"); + client.DefaultRequestHeaders.Add("X-UDID", udid); + + SetClientProfile(client, profile); + + var response = await client.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + response.StatusCode.Should().Be(HttpStatusCode.Found); + + var cookieHeader = BuildCookieHeader(response); + + client.DefaultRequestHeaders.Remove("Cookie"); + client.DefaultRequestHeaders.Add("Cookie", cookieHeader); + + return cookieHeader; + } + + private void SetClientProfile(string profile) + { + _client.DefaultRequestHeaders.Remove("X-UAuth-ClientProfile"); + _client.DefaultRequestHeaders.Add("X-UAuth-ClientProfile", profile); + } + + private void SetClientProfile(HttpClient client, string profile) + { + client.DefaultRequestHeaders.Remove("X-UAuth-ClientProfile"); + client.DefaultRequestHeaders.Add("X-UAuth-ClientProfile", profile); + } + + private static string BuildCookieHeader(HttpResponseMessage response) + { + var cookies = response.Headers.GetValues("Set-Cookie") + .Select(x => x.Split(';')[0]); + + return string.Join("; ", cookies); + } + + private Task RefreshAsync() + { + return _client.PostAsync("/auth/refresh", null); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserProfileTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserProfileTests.cs new file mode 100644 index 00000000..4859ea70 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserProfileTests.cs @@ -0,0 +1,215 @@ +using CodeBeam.UltimateAuth.Users.Contracts; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using System.Net; +using System.Net.Http.Json; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public class UserProfileTests : IClassFixture +{ + private readonly HttpClient _client; + + public UserProfileTests(AuthServerFactory factory) + { + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + _client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); + _client.DefaultRequestHeaders.Add("X-UDID", "test-device-1234567890123456"); + } + + [Fact] + public async Task Profile_Switch_Should_Return_Correct_Profile_Data() + { + var loginResponse = await _client.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + var cookie = loginResponse.Headers.GetValues("Set-Cookie").First(); + _client.DefaultRequestHeaders.Add("Cookie", cookie); + + var defaultResponse = await _client.PostAsJsonAsync("/auth/me/profile/get", new { }); + defaultResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var defaultProfile = await defaultResponse.Content.ReadFromJsonAsync(); + defaultProfile.Should().NotBeNull(); + + var createResponse = await _client.PostAsJsonAsync("/auth/me/profile/create", new CreateProfileRequest + { + ProfileKey = ProfileKey.Parse("business", null) + }); + + createResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var updateResponse = await _client.PostAsJsonAsync("/auth/me/profile/update", new UpdateProfileRequest() + { + ProfileKey = ProfileKey.Parse("business", null), + DisplayName = "Updated Business Name" + }); + + updateResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var businessResponse = await _client.PostAsJsonAsync("/auth/me/profile/get", new GetProfileRequest() + { + ProfileKey = ProfileKey.Parse("business", null) + }); + + businessResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var businessProfile = await businessResponse.Content.ReadFromJsonAsync(); + + businessProfile.Should().NotBeNull(); + businessProfile!.DisplayName.Should().Be("Updated Business Name"); + + var defaultAgainResponse = await _client.PostAsJsonAsync("/auth/me/profile/get", new { }); + + var defaultAgain = await defaultAgainResponse.Content.ReadFromJsonAsync(); + + defaultAgain!.DisplayName.Should().Be(defaultProfile!.DisplayName); + } + + [Fact] + public async Task GetMe_Without_ProfileKey_Should_Return_Default_Profile() + { + var login = await _client.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + var cookie = login.Headers.GetValues("Set-Cookie").First(); + _client.DefaultRequestHeaders.Add("Cookie", cookie); + + var response = await _client.PostAsJsonAsync("/auth/me/profile/get", new { }); + + var profile = await response.Content.ReadFromJsonAsync(); + + profile.Should().NotBeNull(); + profile!.ProfileKey.Value.Should().Be("default"); + } + + [Fact] + public async Task Should_Not_Found_NonDefault_Profile_When_Not_Created() + { + var login = await _client.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + var cookie = login.Headers.GetValues("Set-Cookie").First(); + _client.DefaultRequestHeaders.Add("Cookie", cookie); + + var response = await _client.PostAsJsonAsync("/auth/me/profile/get", new + { + profileKey = "business" + }); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Should_Not_Create_Duplicate_Profile() + { + var login = await Login(); + + var key = ProfileKey.Parse($"business-{Guid.NewGuid()}", null); + + var cookie = login.Headers.GetValues("Set-Cookie").First(); + _client.DefaultRequestHeaders.Add("Cookie", cookie); + + var request = new CreateProfileRequest + { + ProfileKey = key + }; + + var first = await _client.PostAsJsonAsync("/auth/me/profile/create", request); + first.StatusCode.Should().Be(HttpStatusCode.OK); + + var second = await _client.PostAsJsonAsync("/auth/me/profile/create", request); + second.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task Should_Not_Delete_Default_Profile() + { + var login = await Login(); + var cookie = login.Headers.GetValues("Set-Cookie").First(); + _client.DefaultRequestHeaders.Add("Cookie", cookie); + + var response = await _client.PostAsJsonAsync("/auth/me/profile/delete", new + { + profileKey = "default" + }); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task Should_Not_Update_NonExisting_Profile() + { + var login = await Login(); + var cookie = login.Headers.GetValues("Set-Cookie").First(); + _client.DefaultRequestHeaders.Add("Cookie", cookie); + + var response = await _client.PostAsJsonAsync("/auth/me/profile/update", new UpdateProfileRequest + { + ProfileKey = ProfileKey.Parse("ghost", null), + DisplayName = "Should Fail" + }); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Deleted_Profile_Should_Not_Be_Returned() + { + var login = await Login(); + var cookie = login.Headers.GetValues("Set-Cookie").First(); + _client.DefaultRequestHeaders.Add("Cookie", cookie); + + var key = ProfileKey.Parse("business", null); + + await _client.PostAsJsonAsync("/auth/me/profile/create", new CreateProfileRequest + { + ProfileKey = key + }); + + await _client.PostAsJsonAsync("/auth/me/profile/delete", new + { + profileKey = key.Value + }); + + var response = await _client.PostAsJsonAsync("/auth/me/profile/get", new GetProfileRequest + { + ProfileKey = key + }); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + + private async Task Login() + { + var response = await _client.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + response.StatusCode.Should().Be(HttpStatusCode.Found); + + var cookie = response.Headers.GetValues("Set-Cookie").First(); + + _client.DefaultRequestHeaders.Remove("Cookie"); + _client.DefaultRequestHeaders.Add("Cookie", cookie); + + return response; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientUserTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientUserTests.cs index eb2cbf2c..aa718bf4 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientUserTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientUserTests.cs @@ -18,12 +18,15 @@ public async Task GetMe_Should_Call_Correct_Endpoint() UserKey = UserKey.FromString("user-1") }; - Request.Setup(x => x.SendFormAsync(It.IsAny())) + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(SuccessJson(response)); var client = CreateUserClient(); await client.Users.GetMeAsync(); - Request.Verify(x => x.SendFormAsync("/auth/me/get"), Times.Once); + + Request.Verify(x => x.SendJsonAsync( + "/auth/me/profile/get", + It.Is(o => o is GetProfileRequest && ((GetProfileRequest)o).ProfileKey == null)), Times.Once); } [Fact] @@ -155,12 +158,15 @@ public async Task GetUser_Should_Call_Admin_Endpoint() UserKey = userKey }; - Request.Setup(x => x.SendFormAsync(It.IsAny())) + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(SuccessJson(response)); var client = CreateUserClient(); await client.Users.GetUserAsync(userKey); - Request.Verify(x => x.SendFormAsync($"/auth/admin/users/{userKey.Value}/profile/get"), Times.Once); + + Request.Verify(x => x.SendJsonAsync( + $"/auth/admin/users/{userKey.Value}/profile/get", + It.Is(o => o is GetProfileRequest && ((GetProfileRequest)o).ProfileKey == null)), Times.Once); } [Fact] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreAuthenticationStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreAuthenticationStoreTests.cs index 35d5a069..79de0b8f 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreAuthenticationStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreAuthenticationStoreTests.cs @@ -22,7 +22,7 @@ public async Task Add_And_Get_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreAuthenticationSecurityStateStore(db, new TenantContext(tenant)); + var store = new EfCoreAuthenticationSecurityStateStore(db, new TenantExecutionContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -50,13 +50,13 @@ public async Task Update_With_RegisterFailure_Should_Increment_Version() await using (var db1 = CreateDb(connection)) { - var store = new EfCoreAuthenticationSecurityStateStore(db1, new TenantContext(tenant)); + var store = new EfCoreAuthenticationSecurityStateStore(db1, new TenantExecutionContext(tenant)); await store.AddAsync(state); } await using (var db2 = CreateDb(connection)) { - var store = new EfCoreAuthenticationSecurityStateStore(db2, new TenantContext(tenant)); + var store = new EfCoreAuthenticationSecurityStateStore(db2, new TenantExecutionContext(tenant)); var existing = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); var updated = existing!.RegisterFailure( @@ -69,7 +69,7 @@ public async Task Update_With_RegisterFailure_Should_Increment_Version() await using (var db3 = CreateDb(connection)) { - var store = new EfCoreAuthenticationSecurityStateStore(db3, new TenantContext(tenant)); + var store = new EfCoreAuthenticationSecurityStateStore(db3, new TenantExecutionContext(tenant)); var result = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); Assert.Equal(1, result!.SecurityVersion); @@ -84,7 +84,7 @@ public async Task Update_With_Wrong_Version_Should_Throw() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreAuthenticationSecurityStateStore(db, new TenantContext(tenant)); + var store = new EfCoreAuthenticationSecurityStateStore(db, new TenantExecutionContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); var state = AuthenticationSecurityState.CreateAccount(tenant, userKey); @@ -107,13 +107,13 @@ public async Task RegisterSuccess_Should_Clear_Failures() await using (var db1 = CreateDb(connection)) { - var store = new EfCoreAuthenticationSecurityStateStore(db1, new TenantContext(tenant)); + var store = new EfCoreAuthenticationSecurityStateStore(db1, new TenantExecutionContext(tenant)); await store.AddAsync(state); } await using (var db2 = CreateDb(connection)) { - var store = new EfCoreAuthenticationSecurityStateStore(db2, new TenantContext(tenant)); + var store = new EfCoreAuthenticationSecurityStateStore(db2, new TenantExecutionContext(tenant)); var existing = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); var updated = existing!.RegisterSuccess(); await store.UpdateAsync(updated, expectedVersion: 1); @@ -121,7 +121,7 @@ public async Task RegisterSuccess_Should_Clear_Failures() await using (var db3 = CreateDb(connection)) { - var store = new EfCoreAuthenticationSecurityStateStore(db3, new TenantContext(tenant)); + var store = new EfCoreAuthenticationSecurityStateStore(db3, new TenantExecutionContext(tenant)); var result = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); Assert.Equal(0, result!.FailedAttempts); @@ -140,7 +140,7 @@ public async Task BeginReset_And_Consume_Should_Work() await using (var db1 = CreateDb(connection)) { - var store = new EfCoreAuthenticationSecurityStateStore(db1, new TenantContext(tenant)); + var store = new EfCoreAuthenticationSecurityStateStore(db1, new TenantExecutionContext(tenant)); await store.AddAsync(state); } @@ -148,7 +148,7 @@ public async Task BeginReset_And_Consume_Should_Work() await using (var db2 = CreateDb(connection)) { - var store = new EfCoreAuthenticationSecurityStateStore(db2, new TenantContext(tenant)); + var store = new EfCoreAuthenticationSecurityStateStore(db2, new TenantExecutionContext(tenant)); var existing = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); var updated = existing!.BeginReset("hash", now, TimeSpan.FromMinutes(10)); await store.UpdateAsync(updated, expectedVersion: 0); @@ -156,7 +156,7 @@ public async Task BeginReset_And_Consume_Should_Work() await using (var db3 = CreateDb(connection)) { - var store = new EfCoreAuthenticationSecurityStateStore(db3, new TenantContext(tenant)); + var store = new EfCoreAuthenticationSecurityStateStore(db3, new TenantExecutionContext(tenant)); var existing = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); var consumed = existing!.ConsumeReset(DateTimeOffset.UtcNow); await store.UpdateAsync(consumed, expectedVersion: 1); @@ -164,7 +164,7 @@ public async Task BeginReset_And_Consume_Should_Work() await using (var db4 = CreateDb(connection)) { - var store = new EfCoreAuthenticationSecurityStateStore(db4, new TenantContext(tenant)); + var store = new EfCoreAuthenticationSecurityStateStore(db4, new TenantExecutionContext(tenant)); var result = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); Assert.NotNull(result!.ResetConsumedAt); } @@ -177,7 +177,7 @@ public async Task Delete_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreAuthenticationSecurityStateStore(db, new TenantContext(tenant)); + var store = new EfCoreAuthenticationSecurityStateStore(db, new TenantExecutionContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -201,8 +201,8 @@ public async Task Should_Not_See_Data_From_Other_Tenant() var tenant1 = TenantKeys.Single; var tenant2 = TenantKey.FromInternal("tenant-2"); - var store1 = new EfCoreAuthenticationSecurityStateStore(db, new TenantContext(tenant1)); - var store2 = new EfCoreAuthenticationSecurityStateStore(db, new TenantContext(tenant2)); + var store1 = new EfCoreAuthenticationSecurityStateStore(db, new TenantExecutionContext(tenant1)); + var store2 = new EfCoreAuthenticationSecurityStateStore(db, new TenantExecutionContext(tenant2)); var userKey = UserKey.FromGuid(Guid.NewGuid()); var state = AuthenticationSecurityState.CreateAccount(tenant1, userKey); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreCredentialStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreCredentialStoreTests.cs index 23e651d1..094b9036 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreCredentialStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreCredentialStoreTests.cs @@ -24,15 +24,20 @@ public async Task Add_And_Get_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCorePasswordCredentialStore(db, new TenantContext(tenant)); + var store = new EfCorePasswordCredentialStore( + db, + new TenantExecutionContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); + var hasher = new TestPasswordHasher(); + var passwordHash = hasher.Hash("123456"); + var credential = PasswordCredential.Create( Guid.NewGuid(), tenant, userKey, - "hash", + passwordHash, CredentialSecurityState.Active(), new CredentialMetadata(), DateTimeOffset.UtcNow); @@ -42,7 +47,8 @@ public async Task Add_And_Get_Should_Work() var result = await store.GetAsync(new CredentialKey(tenant, credential.Id)); Assert.NotNull(result); - Assert.Equal("hash", result!.SecretHash); + + Assert.True(hasher.Verify(result.SecretHash, "123456")); } [Fact] @@ -52,63 +58,73 @@ public async Task Exists_Should_Return_True_When_Exists() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCorePasswordCredentialStore(db, new TenantContext(tenant)); + var store = new EfCorePasswordCredentialStore( + db, new TenantExecutionContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); + var hasher = new TestPasswordHasher(); + var hash = hasher.Hash("123"); + var credential = PasswordCredential.Create( Guid.NewGuid(), tenant, userKey, - "hash", + hash, CredentialSecurityState.Active(), new CredentialMetadata(), DateTimeOffset.UtcNow); await store.AddAsync(credential); + var exists = await store.ExistsAsync(new CredentialKey(tenant, credential.Id)); Assert.True(exists); } [Fact] - public async Task Save_Should_Increment_Version() + public async Task Save_Should_Increment_Version_And_Update_Hash() { using var connection = CreateOpenConnection(); var tenant = TenantKeys.Single; var userKey = UserKey.FromGuid(Guid.NewGuid()); + var hasher = new TestPasswordHasher(); var credential = PasswordCredential.Create( Guid.NewGuid(), tenant, userKey, - "hash", + hasher.Hash("old"), CredentialSecurityState.Active(), new CredentialMetadata(), DateTimeOffset.UtcNow); await using (var db1 = CreateDb(connection)) { - var store1 = new EfCorePasswordCredentialStore(db1, new TenantContext(tenant)); - await store1.AddAsync(credential); + var store = new EfCorePasswordCredentialStore(db1, new TenantExecutionContext(tenant)); + await store.AddAsync(credential); } await using (var db2 = CreateDb(connection)) { - var store2 = new EfCorePasswordCredentialStore(db2, new TenantContext(tenant)); - var existing = await store2.GetAsync(new CredentialKey(tenant, credential.Id)); - var updated = existing!.ChangeSecret("new_hash", DateTimeOffset.UtcNow); - await store2.SaveAsync(updated, expectedVersion: 0); + var store = new EfCorePasswordCredentialStore(db2, new TenantExecutionContext(tenant)); + + var existing = await store.GetAsync(new CredentialKey(tenant, credential.Id)); + + var updated = existing!.ChangeSecret(hasher.Hash("new"), DateTimeOffset.UtcNow); + + await store.SaveAsync(updated, expectedVersion: 0); } await using (var db3 = CreateDb(connection)) { - var store3 = new EfCorePasswordCredentialStore(db3, new TenantContext(tenant)); - var result = await store3.GetAsync(new CredentialKey(tenant, credential.Id)); + var store = new EfCorePasswordCredentialStore(db3, new TenantExecutionContext(tenant)); + + var result = await store.GetAsync(new CredentialKey(tenant, credential.Id)); Assert.Equal(1, result!.Version); - Assert.Equal("new_hash", result.SecretHash); + Assert.True(hasher.Verify(result.SecretHash, "new")); } } @@ -119,31 +135,32 @@ public async Task Save_With_Wrong_Version_Should_Throw() var tenant = TenantKeys.Single; var userKey = UserKey.FromGuid(Guid.NewGuid()); + var hasher = new TestPasswordHasher(); var credential = PasswordCredential.Create( Guid.NewGuid(), tenant, userKey, - "hash", + hasher.Hash("123"), CredentialSecurityState.Active(), new CredentialMetadata(), DateTimeOffset.UtcNow); await using (var db1 = CreateDb(connection)) { - var store1 = new EfCorePasswordCredentialStore(db1, new TenantContext(tenant)); - await store1.AddAsync(credential); + var store = new EfCorePasswordCredentialStore(db1, new TenantExecutionContext(tenant)); + await store.AddAsync(credential); } await using (var db2 = CreateDb(connection)) { - var store2 = new EfCorePasswordCredentialStore(db2, new TenantContext(tenant)); + var store = new EfCorePasswordCredentialStore(db2, new TenantExecutionContext(tenant)); - var existing = await store2.GetAsync(new CredentialKey(tenant, credential.Id)); - var updated = existing!.ChangeSecret("new_hash", DateTimeOffset.UtcNow); + var existing = await store.GetAsync(new CredentialKey(tenant, credential.Id)); + var updated = existing!.ChangeSecret(hasher.Hash("new"), DateTimeOffset.UtcNow); await Assert.ThrowsAsync(() => - store2.SaveAsync(updated, expectedVersion: 999)); + store.SaveAsync(updated, expectedVersion: 999)); } } @@ -156,8 +173,10 @@ public async Task Should_Not_See_Data_From_Other_Tenant() var tenant1 = TenantKeys.Single; var tenant2 = TenantKey.FromInternal("tenant-2"); - var store1 = new EfCorePasswordCredentialStore(db, new TenantContext(tenant1)); - var store2 = new EfCorePasswordCredentialStore(db, new TenantContext(tenant2)); + var hasher = new TestPasswordHasher(); + + var store1 = new EfCorePasswordCredentialStore(db, new TenantExecutionContext(tenant1)); + var store2 = new EfCorePasswordCredentialStore(db, new TenantExecutionContext(tenant2)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -165,12 +184,13 @@ public async Task Should_Not_See_Data_From_Other_Tenant() Guid.NewGuid(), tenant1, userKey, - "hash", + hasher.Hash("123"), CredentialSecurityState.Active(), new CredentialMetadata(), DateTimeOffset.UtcNow); await store1.AddAsync(credential); + var result = await store2.GetAsync(new CredentialKey(tenant2, credential.Id)); Assert.Null(result); @@ -183,7 +203,9 @@ public async Task Soft_Delete_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCorePasswordCredentialStore(db, new TenantContext(tenant)); + var hasher = new TestPasswordHasher(); + + var store = new EfCorePasswordCredentialStore(db, new TenantExecutionContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -191,7 +213,7 @@ public async Task Soft_Delete_Should_Work() Guid.NewGuid(), tenant, userKey, - "hash", + hasher.Hash("123"), CredentialSecurityState.Active(), new CredentialMetadata(), DateTimeOffset.UtcNow); @@ -217,34 +239,38 @@ public async Task Revoke_Should_Persist() var tenant = TenantKeys.Single; var userKey = UserKey.FromGuid(Guid.NewGuid()); + var hasher = new TestPasswordHasher(); var credential = PasswordCredential.Create( Guid.NewGuid(), tenant, userKey, - "hash", + hasher.Hash("123"), CredentialSecurityState.Active(), new CredentialMetadata(), DateTimeOffset.UtcNow); await using (var db1 = CreateDb(connection)) { - var store1 = new EfCorePasswordCredentialStore(db1, new TenantContext(tenant)); - await store1.AddAsync(credential); + var store = new EfCorePasswordCredentialStore(db1, new TenantExecutionContext(tenant)); + await store.AddAsync(credential); } await using (var db2 = CreateDb(connection)) { - var store2 = new EfCorePasswordCredentialStore(db2, new TenantContext(tenant)); - var existing = await store2.GetAsync(new CredentialKey(tenant, credential.Id)); + var store = new EfCorePasswordCredentialStore(db2, new TenantExecutionContext(tenant)); + + var existing = await store.GetAsync(new CredentialKey(tenant, credential.Id)); var revoked = existing!.Revoke(DateTimeOffset.UtcNow); - await store2.SaveAsync(revoked, expectedVersion: 0); + + await store.SaveAsync(revoked, expectedVersion: 0); } await using (var db3 = CreateDb(connection)) { - var store3 = new EfCorePasswordCredentialStore(db3, new TenantContext(tenant)); - var result = await store3.GetAsync(new CredentialKey(tenant, credential.Id)); + var store = new EfCorePasswordCredentialStore(db3, new TenantExecutionContext(tenant)); + + var result = await store.GetAsync(new CredentialKey(tenant, credential.Id)); Assert.True(result!.IsRevoked); } @@ -257,37 +283,40 @@ public async Task ChangeSecret_Should_Update_SecurityState() var tenant = TenantKeys.Single; var userKey = UserKey.FromGuid(Guid.NewGuid()); + var hasher = new TestPasswordHasher(); var credential = PasswordCredential.Create( Guid.NewGuid(), tenant, userKey, - "hash", + hasher.Hash("old"), CredentialSecurityState.Active(), new CredentialMetadata(), DateTimeOffset.UtcNow); await using (var db1 = CreateDb(connection)) { - var store1 = new EfCorePasswordCredentialStore(db1, new TenantContext(tenant)); - await store1.AddAsync(credential); + var store = new EfCorePasswordCredentialStore(db1, new TenantExecutionContext(tenant)); + await store.AddAsync(credential); } await using (var db2 = CreateDb(connection)) { - var store2 = new EfCorePasswordCredentialStore(db2, new TenantContext(tenant)); - var existing = await store2.GetAsync(new CredentialKey(tenant, credential.Id)); - var updated = existing!.ChangeSecret("new_hash", DateTimeOffset.UtcNow); + var store = new EfCorePasswordCredentialStore(db2, new TenantExecutionContext(tenant)); - await store2.SaveAsync(updated, expectedVersion: 0); + var existing = await store.GetAsync(new CredentialKey(tenant, credential.Id)); + var updated = existing!.ChangeSecret(hasher.Hash("new"), DateTimeOffset.UtcNow); + + await store.SaveAsync(updated, expectedVersion: 0); } await using (var db3 = CreateDb(connection)) { - var store3 = new EfCorePasswordCredentialStore(db3, new TenantContext(tenant)); - var result = await store3.GetAsync(new CredentialKey(tenant, credential.Id)); + var store = new EfCorePasswordCredentialStore(db3, new TenantExecutionContext(tenant)); + + var result = await store.GetAsync(new CredentialKey(tenant, credential.Id)); - Assert.Equal("new_hash", result!.SecretHash); + Assert.True(hasher.Verify(result!.SecretHash, "new")); Assert.NotNull(result.UpdatedAt); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreRoleStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreRoleStoreTests.cs index 8418e86c..92147b93 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreRoleStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreRoleStoreTests.cs @@ -24,7 +24,7 @@ public async Task Add_And_Get_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); var role = Role.Create( null, @@ -49,7 +49,7 @@ public async Task Add_With_Duplicate_Name_Should_Throw() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); var role1 = Role.Create(null, tenant, "admin", null, DateTimeOffset.UtcNow); var role2 = Role.Create(null, tenant, "ADMIN", null, DateTimeOffset.UtcNow); @@ -69,7 +69,7 @@ public async Task Save_Should_Increment_Version() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); var role = Role.Create(null, tenant, "admin", null, DateTimeOffset.UtcNow); roleId = role.Id; await store.AddAsync(role); @@ -77,7 +77,7 @@ public async Task Save_Should_Increment_Version() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); var existing = await store.GetAsync(new RoleKey(tenant, roleId)); var updated = existing!.Rename("admin2", DateTimeOffset.UtcNow); await store.SaveAsync(updated, expectedVersion: 0); @@ -85,7 +85,7 @@ public async Task Save_Should_Increment_Version() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); var result = await store.GetAsync(new RoleKey(tenant, roleId)); Assert.Equal(1, result!.Version); @@ -103,7 +103,7 @@ public async Task Save_With_Wrong_Version_Should_Throw() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); var role = Role.Create(null, tenant, "admin", null, DateTimeOffset.UtcNow); roleId = role.Id; await store.AddAsync(role); @@ -111,7 +111,7 @@ public async Task Save_With_Wrong_Version_Should_Throw() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); var existing = await store.GetAsync(new RoleKey(tenant, roleId)); var updated = existing!.Rename("admin2", DateTimeOffset.UtcNow); @@ -131,7 +131,7 @@ public async Task Rename_To_Existing_Name_Should_Throw() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); var role1 = Role.Create(null, tenant, "admin", null, DateTimeOffset.UtcNow); var role2 = Role.Create(null, tenant, "user", null, DateTimeOffset.UtcNow); role1Id = role1.Id; @@ -142,7 +142,7 @@ public async Task Rename_To_Existing_Name_Should_Throw() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); var role = await store.GetAsync(new RoleKey(tenant, role2Id)); var updated = role!.Rename("admin", DateTimeOffset.UtcNow); @@ -160,7 +160,7 @@ public async Task Save_Should_Replace_Permissions() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); var role = Role.Create( null, @@ -176,7 +176,7 @@ public async Task Save_Should_Replace_Permissions() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); var existing = await store.GetAsync(new RoleKey(tenant, roleId)); var updated = existing!.SetPermissions( new[] @@ -189,7 +189,7 @@ public async Task Save_Should_Replace_Permissions() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); var result = await store.GetAsync(new RoleKey(tenant, roleId)); Assert.Single(result!.Permissions); @@ -207,7 +207,7 @@ public async Task Soft_Delete_Should_Work() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); var role = Role.Create(null, tenant, "admin", null, DateTimeOffset.UtcNow); roleId = role.Id; await store.AddAsync(role); @@ -215,13 +215,13 @@ public async Task Soft_Delete_Should_Work() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); await store.DeleteAsync(new RoleKey(tenant, roleId), 0, DeleteMode.Soft, DateTimeOffset.UtcNow); } await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); var result = await store.GetAsync(new RoleKey(tenant, roleId)); Assert.NotNull(result!.DeletedAt); } @@ -234,7 +234,7 @@ public async Task Query_Should_Filter_And_Page() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); await store.AddAsync(Role.Create(null, tenant, "admin", null, DateTimeOffset.UtcNow)); await store.AddAsync(Role.Create(null, tenant, "user", null, DateTimeOffset.UtcNow)); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreSessionStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreSessionStoreTests.cs index ac3d32c5..6eb7d7c9 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreSessionStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreSessionStoreTests.cs @@ -24,7 +24,7 @@ public async Task Create_And_Get_Session_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -90,7 +90,7 @@ public async Task Session_Should_Persist_DeviceContext() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); @@ -127,7 +127,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); var result = await store.GetSessionAsync(sessionId); @@ -167,7 +167,7 @@ public async Task Session_Should_Persist_Claims_And_Metadata() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); @@ -204,7 +204,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); var result = await store.GetSessionAsync(sessionId); @@ -234,7 +234,7 @@ public async Task Revoke_Session_Should_Work() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); @@ -288,7 +288,7 @@ public async Task Should_Not_See_Session_From_Other_Tenant() await using (var db = CreateDb(connection)) { - var store1 = new EfCoreSessionStore(db, new TenantContext(tenant1)); + var store1 = new EfCoreSessionStore(db, new TenantExecutionContext(tenant1)); var root = UAuthSessionRoot.Create(tenant1, userKey, DateTimeOffset.UtcNow); @@ -325,7 +325,7 @@ await store1.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store2 = new EfCoreSessionStore(db, new TenantContext(tenant2)); + var store2 = new EfCoreSessionStore(db, new TenantExecutionContext(tenant2)); var result = await store2.GetSessionAsync(sessionId); @@ -343,7 +343,7 @@ public async Task ExecuteAsync_Should_Rollback_On_Error() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); await Assert.ThrowsAsync(async () => { @@ -368,7 +368,7 @@ public async Task GetSessionsByChain_Should_Return_Sessions() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); chainId = SessionChainId.New(); @@ -405,7 +405,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); var sessions = await store.GetSessionsByChainAsync(chainId); Assert.Single(sessions); } @@ -423,7 +423,7 @@ public async Task ExecuteAsync_Should_Commit_Multiple_Operations() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); await store.ExecuteAsync(async ct => { @@ -460,7 +460,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); var result = await store.GetSessionAsync(sessionId); @@ -480,7 +480,7 @@ public async Task ExecuteAsync_Should_Rollback_All_On_Failure() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); await Assert.ThrowsAsync(async () => { @@ -516,7 +516,7 @@ public async Task RevokeChainCascade_Should_Revoke_All_Sessions() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); chainId = SessionChainId.New(); @@ -554,7 +554,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); await store.ExecuteAsync(async ct => { @@ -564,7 +564,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); var sessions = await store.GetSessionsByChainAsync(chainId); Assert.All(sessions, s => Assert.True(s.IsRevoked)); @@ -584,7 +584,7 @@ public async Task SetActiveSession_Should_Work() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); @@ -612,7 +612,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); var active = await store.GetActiveSessionIdAsync(chainId); Assert.Equal(sessionId, active); @@ -648,7 +648,7 @@ public async Task SaveSession_Should_Increment_Version() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); chainId = SessionChainId.New(); @@ -686,7 +686,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); await store.ExecuteAsync(async ct => { @@ -699,7 +699,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); var result = await store.GetSessionAsync(sessionId); Assert.Equal(1, result!.Version); @@ -719,7 +719,7 @@ public async Task SaveSession_With_Wrong_Version_Should_Throw() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); chainId = SessionChainId.New(); @@ -757,7 +757,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); await Assert.ThrowsAsync(async () => { @@ -784,7 +784,7 @@ public async Task SaveChain_Should_Increment_Version() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); chainId = SessionChainId.New(); @@ -809,7 +809,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); await store.ExecuteAsync(async ct => { @@ -822,7 +822,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); var result = await store.GetChainAsync(chainId); Assert.Equal(1, result!.Version); @@ -839,7 +839,7 @@ public async Task SaveRoot_Should_Increment_Version() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); var root = UAuthSessionRoot.Create( tenant, @@ -854,7 +854,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); await store.ExecuteAsync(async ct => { @@ -867,7 +867,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); var result = await store.GetRootByUserAsync(userKey); Assert.Equal(1, result!.Version); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreTokenStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreTokenStoreTests.cs index c7bd8179..87445ef0 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreTokenStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreTokenStoreTests.cs @@ -22,7 +22,7 @@ public async Task Store_And_Find_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreRefreshTokenStore(db, new TenantContext(tenant)); + var store = new EfCoreRefreshTokenStore(db, new TenantExecutionContext(tenant)); AuthSessionId.TryCreate(ValidRaw, out var sessionId); var token = RefreshToken.Create( @@ -58,7 +58,7 @@ public async Task Revoke_Should_Set_RevokedAt() await using (var db = CreateDb(connection)) { - var store = new EfCoreRefreshTokenStore(db, new TenantContext(tenant)); + var store = new EfCoreRefreshTokenStore(db, new TenantExecutionContext(tenant)); var token = RefreshToken.Create( TokenId.From(Guid.NewGuid()), @@ -79,7 +79,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreRefreshTokenStore(db, new TenantContext(tenant)); + var store = new EfCoreRefreshTokenStore(db, new TenantExecutionContext(tenant)); await store.ExecuteAsync(async ct => { @@ -89,7 +89,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreRefreshTokenStore(db, new TenantContext(tenant)); + var store = new EfCoreRefreshTokenStore(db, new TenantExecutionContext(tenant)); var result = await store.FindByHashAsync(tokenHash); Assert.NotNull(result!.RevokedAt); @@ -103,7 +103,7 @@ public async Task Store_Outside_Transaction_Should_Throw() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreRefreshTokenStore(db, new TenantContext(tenant)); + var store = new EfCoreRefreshTokenStore(db, new TenantExecutionContext(tenant)); AuthSessionId.TryCreate(ValidRaw, out var sessionId); var token = RefreshToken.Create( diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserIdentifierStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserIdentifierStoreTests.cs index 4fd806af..c980329d 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserIdentifierStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserIdentifierStoreTests.cs @@ -23,7 +23,7 @@ public async Task Add_And_Get_Should_Work() using var connection = CreateOpenConnection(); await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserIdentifierStore(db, new TenantContext(tenant)); + var store = new EfCoreUserIdentifierStore(db, new TenantExecutionContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); var identifier = UserIdentifier.Create( @@ -49,7 +49,7 @@ public async Task Exists_Should_Return_True_When_Exists() using var connection = CreateOpenConnection(); await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserIdentifierStore(db, new TenantContext(tenant)); + var store = new EfCoreUserIdentifierStore(db, new TenantExecutionContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); var identifier = UserIdentifier.Create( @@ -77,7 +77,7 @@ public async Task Save_With_Wrong_Version_Should_Throw() var userKey = UserKey.FromGuid(Guid.NewGuid()); await using var db1 = CreateDb(connection); - var store1 = new EfCoreUserIdentifierStore(db1, new TenantContext(tenant)); + var store1 = new EfCoreUserIdentifierStore(db1, new TenantExecutionContext(tenant)); var identifier = UserIdentifier.Create( Guid.NewGuid(), @@ -92,7 +92,7 @@ public async Task Save_With_Wrong_Version_Should_Throw() await store1.AddAsync(identifier); await using var db2 = CreateDb(connection); - var store2 = new EfCoreUserIdentifierStore(db2, new TenantContext(tenant)); + var store2 = new EfCoreUserIdentifierStore(db2, new TenantExecutionContext(tenant)); var updated = identifier.SetPrimary(DateTimeOffset.UtcNow); @@ -120,13 +120,13 @@ public async Task Save_Should_Increment_Version() await using (var db1 = CreateDb(connection)) { - var store1 = new EfCoreUserIdentifierStore(db1, new TenantContext(tenant)); + var store1 = new EfCoreUserIdentifierStore(db1, new TenantExecutionContext(tenant)); await store1.AddAsync(identifier); } await using (var db2 = CreateDb(connection)) { - var store2 = new EfCoreUserIdentifierStore(db2, new TenantContext(tenant)); + var store2 = new EfCoreUserIdentifierStore(db2, new TenantExecutionContext(tenant)); var existing = await store2.GetAsync(identifier.Id); var updated = existing!.SetPrimary(DateTimeOffset.UtcNow); await store2.SaveAsync(updated, expectedVersion: 0); @@ -134,7 +134,7 @@ public async Task Save_Should_Increment_Version() await using (var db3 = CreateDb(connection)) { - var store3 = new EfCoreUserIdentifierStore(db3, new TenantContext(tenant)); + var store3 = new EfCoreUserIdentifierStore(db3, new TenantExecutionContext(tenant)); var result = await store3.GetAsync(identifier.Id); Assert.Equal(1, result!.Version); } @@ -147,8 +147,8 @@ public async Task Should_Not_See_Data_From_Other_Tenant() await using var db = CreateDb(connection); var tenant1 = TenantKeys.Single; var tenant2 = TenantKey.FromInternal("tenant-2"); - var store1 = new EfCoreUserIdentifierStore(db, new TenantContext(tenant1)); - var store2 = new EfCoreUserIdentifierStore(db, new TenantContext(tenant2)); + var store1 = new EfCoreUserIdentifierStore(db, new TenantExecutionContext(tenant1)); + var store2 = new EfCoreUserIdentifierStore(db, new TenantExecutionContext(tenant2)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -174,7 +174,7 @@ public async Task Soft_Delete_Should_Work() using var connection = CreateOpenConnection(); await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserIdentifierStore(db, new TenantContext(tenant)); + var store = new EfCoreUserIdentifierStore(db, new TenantExecutionContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); var identifier = UserIdentifier.Create( diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserLifecycleStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserLifecycleStoreTests.cs index 0c7c0333..f07ef861 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserLifecycleStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserLifecycleStoreTests.cs @@ -23,7 +23,7 @@ public async Task Add_And_Get_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserLifecycleStore(db, new TenantContext(tenant)); + var store = new EfCoreUserLifecycleStore(db, new TenantExecutionContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -48,7 +48,7 @@ public async Task Exists_Should_Return_True_When_Exists() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserLifecycleStore(db, new TenantContext(tenant)); + var store = new EfCoreUserLifecycleStore(db, new TenantExecutionContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -78,13 +78,13 @@ public async Task Save_Should_Increment_Version() await using (var db1 = CreateDb(connection)) { - var store1 = new EfCoreUserLifecycleStore(db1, new TenantContext(tenant)); + var store1 = new EfCoreUserLifecycleStore(db1, new TenantExecutionContext(tenant)); await store1.AddAsync(lifecycle); } await using (var db2 = CreateDb(connection)) { - var store2 = new EfCoreUserLifecycleStore(db2, new TenantContext(tenant)); + var store2 = new EfCoreUserLifecycleStore(db2, new TenantExecutionContext(tenant)); var existing = await store2.GetAsync(new UserLifecycleKey(tenant, userKey)); var updated = existing!.ChangeStatus(DateTimeOffset.UtcNow, UserStatus.Suspended); await store2.SaveAsync(updated, expectedVersion: 0); @@ -92,7 +92,7 @@ public async Task Save_Should_Increment_Version() await using (var db3 = CreateDb(connection)) { - var store3 = new EfCoreUserLifecycleStore(db3, new TenantContext(tenant)); + var store3 = new EfCoreUserLifecycleStore(db3, new TenantExecutionContext(tenant)); var result = await store3.GetAsync(new UserLifecycleKey(tenant, userKey)); Assert.Equal(1, result!.Version); @@ -115,13 +115,13 @@ public async Task Save_With_Wrong_Version_Should_Throw() await using (var db1 = CreateDb(connection)) { - var store1 = new EfCoreUserLifecycleStore(db1, new TenantContext(tenant)); + var store1 = new EfCoreUserLifecycleStore(db1, new TenantExecutionContext(tenant)); await store1.AddAsync(lifecycle); } await using (var db2 = CreateDb(connection)) { - var store2 = new EfCoreUserLifecycleStore(db2, new TenantContext(tenant)); + var store2 = new EfCoreUserLifecycleStore(db2, new TenantExecutionContext(tenant)); var existing = await store2.GetAsync(new UserLifecycleKey(tenant, userKey)); var updated = existing!.ChangeStatus(DateTimeOffset.UtcNow, UserStatus.Suspended); @@ -139,8 +139,8 @@ public async Task Should_Not_See_Data_From_Other_Tenant() var tenant1 = TenantKeys.Single; var tenant2 = TenantKey.FromInternal("tenant-2"); - var store1 = new EfCoreUserLifecycleStore(db, new TenantContext(tenant1)); - var store2 = new EfCoreUserLifecycleStore(db, new TenantContext(tenant2)); + var store1 = new EfCoreUserLifecycleStore(db, new TenantExecutionContext(tenant1)); + var store2 = new EfCoreUserLifecycleStore(db, new TenantExecutionContext(tenant2)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -163,7 +163,7 @@ public async Task Soft_Delete_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserLifecycleStore(db, new TenantContext(tenant)); + var store = new EfCoreUserLifecycleStore(db, new TenantExecutionContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -201,13 +201,13 @@ public async Task Delete_Should_Increment_SecurityVersion() await using (var db1 = CreateDb(connection)) { - var store1 = new EfCoreUserLifecycleStore(db1, new TenantContext(tenant)); + var store1 = new EfCoreUserLifecycleStore(db1, new TenantExecutionContext(tenant)); await store1.AddAsync(lifecycle); } await using (var db2 = CreateDb(connection)) { - var store2 = new EfCoreUserLifecycleStore(db2, new TenantContext(tenant)); + var store2 = new EfCoreUserLifecycleStore(db2, new TenantExecutionContext(tenant)); var existing = await store2.GetAsync(new UserLifecycleKey(tenant, userKey)); var deleted = existing!.MarkDeleted(DateTimeOffset.UtcNow); @@ -217,7 +217,7 @@ public async Task Delete_Should_Increment_SecurityVersion() await using (var db3 = CreateDb(connection)) { - var store3 = new EfCoreUserLifecycleStore(db3, new TenantContext(tenant)); + var store3 = new EfCoreUserLifecycleStore(db3, new TenantExecutionContext(tenant)); var result = await store3.GetAsync(new UserLifecycleKey(tenant, userKey)); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserProfileStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserProfileStoreTests.cs index 87d84078..0a154f9b 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserProfileStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserProfileStoreTests.cs @@ -3,6 +3,7 @@ using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.EntityFrameworkCore; using CodeBeam.UltimateAuth.Users.Reference; using Microsoft.Data.Sqlite; @@ -23,7 +24,7 @@ public async Task Add_And_Get_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserProfileStore(db, new TenantContext(tenant)); + var store = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -31,6 +32,7 @@ public async Task Add_And_Get_Should_Work() Guid.NewGuid(), tenant, userKey, + ProfileKey.Default, DateTimeOffset.UtcNow, displayName: "display", firstName: "first", @@ -38,7 +40,7 @@ public async Task Add_And_Get_Should_Work() ); await store.AddAsync(profile); - var result = await store.GetAsync(new UserProfileKey(tenant, userKey)); + var result = await store.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default)); Assert.NotNull(result); Assert.Equal(userKey, result!.UserKey); @@ -51,7 +53,7 @@ public async Task Exists_Should_Return_True_When_Exists() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserProfileStore(db, new TenantContext(tenant)); + var store = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -59,6 +61,7 @@ public async Task Exists_Should_Return_True_When_Exists() Guid.NewGuid(), tenant, userKey, + ProfileKey.Default, DateTimeOffset.UtcNow, displayName: "display", firstName: "first", @@ -66,7 +69,7 @@ public async Task Exists_Should_Return_True_When_Exists() ); await store.AddAsync(profile); - var exists = await store.ExistsAsync(new UserProfileKey(tenant, userKey)); + var exists = await store.ExistsAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default)); Assert.True(exists); } @@ -83,6 +86,7 @@ public async Task Save_Should_Increment_Version() Guid.NewGuid(), tenant, userKey, + ProfileKey.Default, DateTimeOffset.UtcNow, displayName: "display", firstName: "first", @@ -91,22 +95,22 @@ public async Task Save_Should_Increment_Version() await using (var db1 = CreateDb(connection)) { - var store1 = new EfCoreUserProfileStore(db1, new TenantContext(tenant)); + var store1 = new EfCoreUserProfileStore(db1, new TenantExecutionContext(tenant)); await store1.AddAsync(profile); } await using (var db2 = CreateDb(connection)) { - var store2 = new EfCoreUserProfileStore(db2, new TenantContext(tenant)); - var existing = await store2.GetAsync(new UserProfileKey(tenant, userKey)); + var store2 = new EfCoreUserProfileStore(db2, new TenantExecutionContext(tenant)); + var existing = await store2.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default)); var updated = existing!.UpdateName(existing.FirstName, existing.LastName, "new", DateTimeOffset.UtcNow); await store2.SaveAsync(updated, expectedVersion: 0); } await using (var db3 = CreateDb(connection)) { - var store3 = new EfCoreUserProfileStore(db3, new TenantContext(tenant)); - var result = await store3.GetAsync(new UserProfileKey(tenant, userKey)); + var store3 = new EfCoreUserProfileStore(db3, new TenantExecutionContext(tenant)); + var result = await store3.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default)); Assert.Equal(1, result!.Version); Assert.Equal("new", result.DisplayName); @@ -125,6 +129,7 @@ public async Task Save_With_Wrong_Version_Should_Throw() Guid.NewGuid(), tenant, userKey, + ProfileKey.Default, DateTimeOffset.UtcNow, displayName: "display", firstName: "first", @@ -133,14 +138,14 @@ public async Task Save_With_Wrong_Version_Should_Throw() await using (var db1 = CreateDb(connection)) { - var store1 = new EfCoreUserProfileStore(db1, new TenantContext(tenant)); + var store1 = new EfCoreUserProfileStore(db1, new TenantExecutionContext(tenant)); await store1.AddAsync(profile); } await using (var db2 = CreateDb(connection)) { - var store2 = new EfCoreUserProfileStore(db2, new TenantContext(tenant)); - var existing = await store2.GetAsync(new UserProfileKey(tenant, userKey)); + var store2 = new EfCoreUserProfileStore(db2, new TenantExecutionContext(tenant)); + var existing = await store2.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default)); var updated = existing!.UpdateName(existing.FirstName, existing.LastName, "new", DateTimeOffset.UtcNow); await Assert.ThrowsAsync(() => @@ -157,8 +162,8 @@ public async Task Should_Not_See_Data_From_Other_Tenant() var tenant1 = TenantKeys.Single; var tenant2 = TenantKey.FromInternal("tenant-2"); - var store1 = new EfCoreUserProfileStore(db, new TenantContext(tenant1)); - var store2 = new EfCoreUserProfileStore(db, new TenantContext(tenant2)); + var store1 = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant1)); + var store2 = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant2)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -166,6 +171,7 @@ public async Task Should_Not_See_Data_From_Other_Tenant() Guid.NewGuid(), tenant1, userKey, + ProfileKey.Default, DateTimeOffset.UtcNow, displayName: "display", firstName: "first", @@ -173,7 +179,7 @@ public async Task Should_Not_See_Data_From_Other_Tenant() ); await store1.AddAsync(profile); - var result = await store2.GetAsync(new UserProfileKey(tenant2, userKey)); + var result = await store2.GetAsync(new UserProfileKey(tenant2, userKey, ProfileKey.Default)); Assert.Null(result); } @@ -185,7 +191,7 @@ public async Task Soft_Delete_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserProfileStore(db, new TenantContext(tenant)); + var store = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -193,6 +199,7 @@ public async Task Soft_Delete_Should_Work() Guid.NewGuid(), tenant, userKey, + ProfileKey.Default, DateTimeOffset.UtcNow, displayName: "display", firstName: "first", @@ -202,14 +209,195 @@ public async Task Soft_Delete_Should_Work() await store.AddAsync(profile); await store.DeleteAsync( - new UserProfileKey(tenant, userKey), + new UserProfileKey(tenant, userKey, ProfileKey.Default), expectedVersion: 0, DeleteMode.Soft, DateTimeOffset.UtcNow); - var result = await store.GetAsync(new UserProfileKey(tenant, userKey)); + var result = await store.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default)); Assert.NotNull(result); Assert.NotNull(result!.DeletedAt); } + + [Fact] + public async Task Same_User_Can_Have_Multiple_Profiles() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var defaultProfile = UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + ProfileKey.Default, + DateTimeOffset.UtcNow, + displayName: "default"); + + var businessProfile = UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + ProfileKey.Parse("business", null), + DateTimeOffset.UtcNow, + displayName: "business"); + + await store.AddAsync(defaultProfile); + await store.AddAsync(businessProfile); + + var p1 = await store.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default)); + var p2 = await store.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Parse("business", null))); + + Assert.NotNull(p1); + Assert.NotNull(p2); + Assert.NotEqual(p1!.ProfileKey, p2!.ProfileKey); + } + + [Fact] + public async Task GetAsync_Should_Return_Correct_Profile_By_ProfileKey() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + await store.AddAsync(UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + ProfileKey.Default, + DateTimeOffset.UtcNow, + displayName: "default")); + + await store.AddAsync(UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + ProfileKey.Parse("business", null), + DateTimeOffset.UtcNow, + displayName: "business")); + + var result = await store.GetAsync( + new UserProfileKey(tenant, userKey, ProfileKey.Parse("business", null))); + + Assert.Equal("business", result!.DisplayName); + } + + [Fact] + public async Task GetByUsersAsync_Should_Filter_By_ProfileKey() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + await store.AddAsync(UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + ProfileKey.Default, + DateTimeOffset.UtcNow, + displayName: "default")); + + await store.AddAsync(UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + ProfileKey.Parse("business", null), + DateTimeOffset.UtcNow, + displayName: "business")); + + var results = await store.GetByUsersAsync( + new[] { userKey }, + ProfileKey.Default); + + Assert.Single(results); + Assert.Equal(ProfileKey.Default, results[0].ProfileKey); + } + + [Fact] + public async Task Should_Not_Allow_Duplicate_ProfileKey_For_Same_User() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var profile1 = UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + ProfileKey.Default, + DateTimeOffset.UtcNow); + + var profile2 = UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + ProfileKey.Default, + DateTimeOffset.UtcNow); + + await store.AddAsync(profile1); + + await Assert.ThrowsAsync(() => + store.AddAsync(profile2)); + } + + [Fact] + public async Task Delete_Should_Not_Affect_Other_Profiles() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var defaultProfile = UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + ProfileKey.Default, + DateTimeOffset.UtcNow); + + var businessProfile = UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + ProfileKey.Parse("business", null), + DateTimeOffset.UtcNow); + + await store.AddAsync(defaultProfile); + await store.AddAsync(businessProfile); + + await store.DeleteAsync( + new UserProfileKey(tenant, userKey, ProfileKey.Default), + 0, + DeleteMode.Soft, + DateTimeOffset.UtcNow); + + var defaultResult = await store.GetAsync( + new UserProfileKey(tenant, userKey, ProfileKey.Default)); + + var businessResult = await store.GetAsync( + new UserProfileKey(tenant, userKey, ProfileKey.Parse("business", null))); + + Assert.NotNull(defaultResult!.DeletedAt); + Assert.Null(businessResult!.DeletedAt); + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserRoleStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserRoleStoreTests.cs index e1062e34..5d9b1264 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserRoleStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserRoleStoreTests.cs @@ -22,7 +22,7 @@ public async Task Assign_And_GetAssignments_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreUserRoleStore(db, new TenantExecutionContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); var roleId = RoleId.New(); @@ -41,7 +41,7 @@ public async Task Assign_Duplicate_Should_Throw() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreUserRoleStore(db, new TenantExecutionContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); var roleId = RoleId.New(); @@ -58,7 +58,7 @@ public async Task Remove_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreUserRoleStore(db, new TenantExecutionContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); var roleId = RoleId.New(); @@ -77,7 +77,7 @@ public async Task Remove_NonExisting_Should_Not_Throw() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreUserRoleStore(db, new TenantExecutionContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); var roleId = RoleId.New(); @@ -92,7 +92,7 @@ public async Task CountAssignments_Should_Return_Correct_Count() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreUserRoleStore(db, new TenantExecutionContext(tenant)); var roleId = RoleId.New(); @@ -111,7 +111,7 @@ public async Task RemoveAssignmentsByRole_Should_Remove_All() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreUserRoleStore(db, new TenantExecutionContext(tenant)); var roleId = RoleId.New(); @@ -137,8 +137,8 @@ public async Task Should_Not_See_Data_From_Other_Tenant() var tenant1 = TenantKeys.Single; var tenant2 = TenantKey.FromInternal("tenant-2"); - var store1 = new EfCoreUserRoleStore(db, new TenantContext(tenant1)); - var store2 = new EfCoreUserRoleStore(db, new TenantContext(tenant2)); + var store1 = new EfCoreUserRoleStore(db, new TenantExecutionContext(tenant1)); + var store2 = new EfCoreUserRoleStore(db, new TenantExecutionContext(tenant2)); var userKey = UserKey.FromGuid(Guid.NewGuid()); var roleId = RoleId.New(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestPasswordHasher.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestPasswordHasher.cs index c6b363ec..a4daef84 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestPasswordHasher.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestPasswordHasher.cs @@ -1,9 +1,33 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Credentials.Contracts; namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; internal sealed class TestPasswordHasher : IUAuthPasswordHasher { - public string Hash(string password) => $"HASH::{password}"; - public bool Verify(string hashedPassword, string providedPassword) => hashedPassword == $"HASH::{providedPassword}"; + public PasswordHash Hash(string password) + { + if (string.IsNullOrWhiteSpace(password)) + throw new UAuthValidationException("password_required"); + + return PasswordHash.Create(PasswordAlgorithms.Legacy, $"TEST::{password}"); + } + + public bool Verify(PasswordHash hash, string secret) + { + if (string.IsNullOrWhiteSpace(secret) || string.IsNullOrWhiteSpace(hash.Hash)) + return false; + + if (hash.Algorithm != PasswordAlgorithms.Legacy) + return false; + + return hash.Hash == $"TEST::{secret}"; + } + + public bool NeedsRehash(PasswordHash hash) + { + return false; + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs index 506c90f1..95474a45 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs @@ -1,5 +1,8 @@ -using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Security.Argon2; +using FluentAssertions; using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -13,74 +16,132 @@ private Argon2PasswordHasher CreateHasher() } [Fact] - public void Hash_ShouldReturn_NonEmptyString() + public void Hash_Should_Return_Valid_PasswordHash() { var hasher = CreateHasher(); + var result = hasher.Hash("password123"); - Assert.False(string.IsNullOrWhiteSpace(result)); - Assert.Contains(".", result); + result.Should().NotBeNull(); + result.Algorithm.Should().Be(PasswordAlgorithms.Argon2); + result.Hash.Should().NotBeNullOrWhiteSpace(); + + var parts = result.Hash.Split('.'); + parts.Length.Should().Be(5); } [Fact] - public void Verify_ShouldReturn_True_ForValidPassword() + public void Verify_Should_Return_True_For_Correct_Password() { var hasher = CreateHasher(); + var hash = hasher.Hash("password123"); + var result = hasher.Verify(hash, "password123"); - Assert.True(result); + result.Should().BeTrue(); } [Fact] - public void Verify_ShouldReturn_False_ForInvalidPassword() + public void Verify_Should_Return_False_For_Wrong_Password() { var hasher = CreateHasher(); + var hash = hasher.Hash("password123"); - var result = hasher.Verify(hash, "wrong-password"); - Assert.False(result); + var result = hasher.Verify(hash, "wrong"); + + result.Should().BeFalse(); } [Fact] - public void Verify_ShouldReturn_False_ForInvalidFormat() + public void Verify_Should_Return_False_For_Invalid_Format() { var hasher = CreateHasher(); - var result = hasher.Verify("invalid-format", "password"); - Assert.False(result); + var invalid = PasswordHash.Create(PasswordAlgorithms.Argon2, "invalid"); + + var result = hasher.Verify(invalid, "password"); + + result.Should().BeFalse(); } [Fact] - public void Hash_ShouldThrow_WhenPasswordIsEmpty() + public void Hash_Should_Throw_When_Password_Is_Empty() { var hasher = CreateHasher(); + Assert.Throws(() => hasher.Hash("")); } [Fact] - public void Hash_ShouldProduce_DifferentHashes_ForSamePassword() + public void Hash_Should_Produce_Different_Hashes_For_Same_Password() { var hasher = CreateHasher(); + var hash1 = hasher.Hash("password123"); var hash2 = hasher.Hash("password123"); - Assert.NotEqual(hash1, hash2); + hash1.Hash.Should().NotBe(hash2.Hash); } [Fact] - public void Verify_ShouldUse_SameSalt_FromHash() + public void Verify_Should_Use_Embedded_Salt_And_Parameters() { var hasher = CreateHasher(); + var hash = hasher.Hash("password123"); - var parts = hash.Split('.'); - Assert.Equal(2, parts.Length); + // parametreleri değiştir (simulate config drift) + var differentOptions = Options.Create(new Argon2Options + { + Iterations = 999, + MemorySizeKb = 999, + Parallelism = 1, + SaltSize = 16, + HashSize = 32 + }); - var salt1 = parts[0]; - var hash2 = hasher.Hash("password123"); - var salt2 = hash2.Split('.')[0]; + var differentHasher = new Argon2PasswordHasher(differentOptions); + + // 🔥 yine de doğrulamalı + var result = differentHasher.Verify(hash, "password123"); + + result.Should().BeTrue(); + } + + [Fact] + public void NeedsRehash_Should_Return_True_When_Parameters_Changed() + { + var hasher = CreateHasher(); + + var hash = hasher.Hash("password123"); + + var differentOptions = Options.Create(new Argon2Options + { + Iterations = 999, + MemorySizeKb = 999, + Parallelism = 1, + SaltSize = 16, + HashSize = 32 + }); + + var differentHasher = new Argon2PasswordHasher(differentOptions); + + var result = differentHasher.NeedsRehash(hash); + + result.Should().BeTrue(); + } + + [Fact] + public void NeedsRehash_Should_Return_False_When_Parameters_Match() + { + var hasher = CreateHasher(); + + var hash = hasher.Hash("password123"); + + var result = hasher.NeedsRehash(hash); - Assert.NotEqual(salt1, salt2); // random salt doğrulama + result.Should().BeFalse(); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs index ef600fd8..7644b744 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs @@ -13,7 +13,7 @@ public class IdentifierConcurrencyTests [Fact] public async Task Save_should_increment_version() { - var store = new InMemoryUserIdentifierStore(new TenantContext(TenantKeys.Single)); + var store = new InMemoryUserIdentifierStore(new TenantExecutionContext(TenantKeys.Single)); var now = DateTimeOffset.UtcNow; var id = Guid.NewGuid(); @@ -34,7 +34,7 @@ public async Task Save_should_increment_version() [Fact] public async Task Delete_should_throw_when_version_conflicts() { - var store = new InMemoryUserIdentifierStore(new TenantContext(TenantKeys.Single)); + var store = new InMemoryUserIdentifierStore(new TenantExecutionContext(TenantKeys.Single)); var now = DateTimeOffset.UtcNow; var id = Guid.NewGuid(); @@ -57,7 +57,7 @@ await Assert.ThrowsAsync(async () => [Fact] public async Task Parallel_SetPrimary_should_conflict_deterministic() { - var store = new InMemoryUserIdentifierStore(new TenantContext(TenantKeys.Single)); + var store = new InMemoryUserIdentifierStore(new TenantExecutionContext(TenantKeys.Single)); var now = DateTimeOffset.UtcNow; var id = Guid.NewGuid(); @@ -103,7 +103,7 @@ public async Task Parallel_SetPrimary_should_conflict_deterministic() [Fact] public async Task Update_should_throw_concurrency_when_versions_conflict() { - var store = new InMemoryUserIdentifierStore(new TenantContext(TenantKeys.Single)); + var store = new InMemoryUserIdentifierStore(new TenantExecutionContext(TenantKeys.Single)); var id = Guid.NewGuid(); var now = DateTimeOffset.UtcNow; var tenant = TenantKey.Single; @@ -130,7 +130,7 @@ await Assert.ThrowsAsync(async () => [Fact] public async Task Parallel_updates_should_result_in_single_success_deterministic() { - var store = new InMemoryUserIdentifierStore(new TenantContext(TenantKeys.Single)); + var store = new InMemoryUserIdentifierStore(new TenantExecutionContext(TenantKeys.Single)); var now = DateTimeOffset.UtcNow; var tenant = TenantKey.Single; var id = Guid.NewGuid(); @@ -181,7 +181,7 @@ public async Task Parallel_updates_should_result_in_single_success_deterministic [Fact] public async Task High_contention_updates_should_allow_only_one_success() { - var store = new InMemoryUserIdentifierStore(new TenantContext(TenantKeys.Single)); + var store = new InMemoryUserIdentifierStore(new TenantExecutionContext(TenantKeys.Single)); var now = DateTimeOffset.UtcNow; var tenant = TenantKey.Single; var id = Guid.NewGuid(); @@ -227,7 +227,7 @@ public async Task High_contention_updates_should_allow_only_one_success() [Fact] public async Task High_contention_SetPrimary_should_allow_only_one_deterministic() { - var store = new InMemoryUserIdentifierStore(new TenantContext(TenantKeys.Single)); + var store = new InMemoryUserIdentifierStore(new TenantExecutionContext(TenantKeys.Single)); var now = DateTimeOffset.UtcNow; var tenant = TenantKey.Single; @@ -276,7 +276,7 @@ public async Task High_contention_SetPrimary_should_allow_only_one_deterministic [Fact] public async Task Two_identifiers_racing_for_primary_should_allow() { - var store = new InMemoryUserIdentifierStore(new TenantContext(TenantKeys.Single)); + var store = new InMemoryUserIdentifierStore(new TenantExecutionContext(TenantKeys.Single)); var now = DateTimeOffset.UtcNow; var tenant = TenantKey.Single; var user = TestUsers.Admin;