From 55bc8f03cfa7047c0ad906e5b6d1eb21d6d1a2bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Wed, 1 Apr 2026 20:15:31 +0300 Subject: [PATCH 1/5] Final Preview Review --- .../Components/Dialogs/UsersDialog.razor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) { From 919a2db904129694749bfd3a72ba2dcd95bbb941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sat, 4 Apr 2026 12:31:49 +0300 Subject: [PATCH 2/5] Minor Polish --- .../Components/Dialogs/UsersDialog.razor.cs | 2 +- .../Options/Validators/UAuthTokenOptionsValidator.cs | 2 +- .../Infrastructure/UAuthRequestClient.cs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) 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/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/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; } From ec8a424f5c13a204e78843c56434ce8e541ddd50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 6 Apr 2026 02:36:34 +0300 Subject: [PATCH 3/5] Added Multi Profile Support --- .../UserSeedContributor.cs | 4 +- .../Program.cs | 1 + .../20260405222857_MultiProfile.Designer.cs | 716 ++++++++++++++++++ .../Migrations/20260405222857_MultiProfile.cs | 49 ++ .../Migrations/UAuthDbContextModelSnapshot.cs | 8 +- .../uauth.db-shm | Bin 32768 -> 32768 bytes .../uauth.db-wal | Bin 716912 -> 881712 bytes .../Defaults/UAuthActions.cs | 4 + .../Auth/AuthStateSnapshotFactory.cs | 3 +- .../Abstractions/IUserEndpointHandler.cs | 6 + .../Endpoints/UAuthEndpointRegistrar.cs | 20 +- .../Options/UAuthServerOptions.cs | 3 + .../Options/UAuthUserProfileOptions.cs | 11 + .../Services/Abstractions/IUserClient.cs | 8 +- .../Services/UAuthUserClient.cs | 69 +- .../Domain/ProfileKey.cs | 66 ++ .../Domain/ProfileKeyJsonConverter.cs | 28 + .../Dtos/UserQuery.cs | 1 + .../Dtos/UserView.cs | 1 + .../Requests/CreateProfileRequest.cs | 22 + .../Requests/DeleteProfileRequest.cs | 6 + .../Requests/GetProfileRequest.cs | 6 + .../Requests/UpdateProfileRequest.cs | 1 + .../Data/UAuthUsersModelBuilder.cs | 9 +- .../Mappers/UserProfileMapper.cs | 4 +- .../Projections/UserProfileProjection.cs | 3 + .../Stores/EfCoreUserProfileStore.cs | 44 +- .../Stores/InMemoryUserProfileStore.cs | 43 +- .../Contracts/UserProfileQuery.cs | 2 + .../Domain/UserProfile.cs | 51 +- .../Domain/UserProfileKey.cs | 4 +- .../Endpoints/UserEndpointHandler.cs | 80 +- .../UserProfileSnapshotProvider.cs | 4 +- .../Mapping/UserProfileMapper.cs | 1 + .../Services/IUserApplicationService.cs | 6 +- .../Services/UserApplicationService.cs | 143 +++- .../Stores/IUserProfileStore.cs | 4 +- .../IUserProfileSnapshotProvider.cs | 2 +- .../LoginTests.cs | 7 +- .../UserProfileTests.cs | 213 ++++++ .../Client/UAuthClientUserTests.cs | 14 +- .../EfCoreUserProfileStoreTests.cs | 204 ++++- 42 files changed, 1806 insertions(+), 65 deletions(-) create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260405222857_MultiProfile.Designer.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260405222857_MultiProfile.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthUserProfileOptions.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Domain/ProfileKey.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Domain/ProfileKeyJsonConverter.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateProfileRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteProfileRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/GetProfileRequest.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/UserProfileTests.cs 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/Migrations/20260405222857_MultiProfile.Designer.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260405222857_MultiProfile.Designer.cs new file mode 100644 index 00000000..5077af89 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260405222857_MultiProfile.Designer.cs @@ -0,0 +1,716 @@ +// +using System; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Migrations +{ + [DbContext(typeof(UAuthDbContext))] + [Migration("20260405222857_MultiProfile")] + partial class MultiProfile + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.AuthenticationSecurityStateProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CredentialType") + .HasColumnType("INTEGER"); + + b.Property("FailedAttempts") + .HasColumnType("INTEGER"); + + b.Property("LastFailedAt") + .HasColumnType("TEXT"); + + b.Property("LockedUntil") + .HasColumnType("TEXT"); + + b.Property("RequiresReauthentication") + .HasColumnType("INTEGER"); + + b.Property("ResetAttempts") + .HasColumnType("INTEGER"); + + b.Property("ResetConsumedAt") + .HasColumnType("TEXT"); + + b.Property("ResetExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ResetRequestedAt") + .HasColumnType("TEXT"); + + b.Property("ResetTokenHash") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.Property("SecurityVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "LockedUntil"); + + b.HasIndex("Tenant", "ResetRequestedAt"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "Scope"); + + b.HasIndex("Tenant", "UserKey", "Scope", "CredentialType") + .IsUnique(); + + b.ToTable("UAuth_Authentication", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.RolePermissionProjection", b => + { + b.Property("Tenant") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Tenant", "RoleId", "Permission"); + + b.HasIndex("Tenant", "Permission"); + + b.HasIndex("Tenant", "RoleId"); + + b.ToTable("UAuth_RolePermissions", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.RoleProjection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "Id") + .IsUnique(); + + b.HasIndex("Tenant", "NormalizedName") + .IsUnique(); + + b.ToTable("UAuth_Roles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.UserRoleProjection", b => + { + b.Property("Tenant") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserKey") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.Property("AssignedAt") + .HasColumnType("TEXT"); + + b.HasKey("Tenant", "UserKey", "RoleId"); + + b.HasIndex("Tenant", "RoleId"); + + b.HasIndex("Tenant", "UserKey"); + + b.ToTable("UAuth_UserRoles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.PasswordCredentialProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "ExpiresAt"); + + b.HasIndex("Tenant", "Id") + .IsUnique(); + + b.HasIndex("Tenant", "RevokedAt"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "DeletedAt"); + + b.ToTable("UAuth_PasswordCredentials", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AbsoluteExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ActiveSessionId") + .HasColumnType("TEXT"); + + b.Property("ChainId") + .HasColumnType("TEXT"); + + b.Property("ClaimsSnapshot") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Device") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("RootId") + .HasColumnType("TEXT"); + + b.Property("RotationCount") + .HasColumnType("INTEGER"); + + b.Property("SecurityVersionAtCreation") + .HasColumnType("INTEGER"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("TouchCount") + .HasColumnType("INTEGER"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "ChainId") + .IsUnique(); + + b.HasIndex("Tenant", "RootId"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "DeviceId"); + + b.ToTable("UAuth_SessionChains", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChainId") + .HasColumnType("TEXT"); + + b.Property("Claims") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Device") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("SecurityVersionAtCreation") + .HasColumnType("INTEGER"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "ChainId"); + + b.HasIndex("Tenant", "ExpiresAt"); + + b.HasIndex("Tenant", "RevokedAt"); + + b.HasIndex("Tenant", "SessionId") + .IsUnique(); + + b.HasIndex("Tenant", "ChainId", "RevokedAt"); + + b.HasIndex("Tenant", "UserKey", "RevokedAt"); + + b.ToTable("UAuth_Sessions", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionRootProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("RootId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SecurityVersion") + .HasColumnType("INTEGER"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "RootId") + .IsUnique(); + + b.HasIndex("Tenant", "UserKey") + .IsUnique(); + + b.ToTable("UAuth_SessionRoots", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.RefreshTokenProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChainId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("TokenId") + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "ChainId"); + + b.HasIndex("Tenant", "ExpiresAt"); + + b.HasIndex("Tenant", "ReplacedByTokenHash"); + + b.HasIndex("Tenant", "SessionId"); + + b.HasIndex("Tenant", "TokenHash") + .IsUnique(); + + b.HasIndex("Tenant", "TokenId"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "ExpiresAt", "RevokedAt"); + + b.HasIndex("Tenant", "TokenHash", "RevokedAt"); + + b.ToTable("UAuth_RefreshTokens", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserIdentifierProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("NormalizedValue") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("VerifiedAt") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "NormalizedValue"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "Type", "NormalizedValue") + .IsUnique(); + + b.HasIndex("Tenant", "UserKey", "IsPrimary"); + + b.HasIndex("Tenant", "UserKey", "Type", "IsPrimary"); + + b.ToTable("UAuth_UserIdentifiers", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserLifecycleProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("SecurityVersion") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey") + .IsUnique(); + + b.ToTable("UAuth_UserLifecycles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserProfileProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasColumnType("TEXT"); + + b.Property("BirthDate") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Culture") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("FirstName") + .HasColumnType("TEXT"); + + b.Property("Gender") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastName") + .HasColumnType("TEXT"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("ProfileKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("TimeZone") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey", "ProfileKey") + .IsUnique(); + + b.ToTable("UAuth_UserProfiles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", b => + { + b.HasOne("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionRootProjection", null) + .WithMany() + .HasForeignKey("Tenant", "RootId") + .HasPrincipalKey("Tenant", "RootId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionProjection", b => + { + b.HasOne("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", null) + .WithMany() + .HasForeignKey("Tenant", "ChainId") + .HasPrincipalKey("Tenant", "ChainId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260405222857_MultiProfile.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260405222857_MultiProfile.cs new file mode 100644 index 00000000..049933f1 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260405222857_MultiProfile.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Migrations +{ + /// + public partial class MultiProfile : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_UAuth_UserProfiles_Tenant_UserKey", + table: "UAuth_UserProfiles"); + + migrationBuilder.AddColumn( + name: "ProfileKey", + table: "UAuth_UserProfiles", + type: "TEXT", + maxLength: 64, + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserProfiles_Tenant_UserKey_ProfileKey", + table: "UAuth_UserProfiles", + columns: new[] { "Tenant", "UserKey", "ProfileKey" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_UAuth_UserProfiles_Tenant_UserKey_ProfileKey", + table: "UAuth_UserProfiles"); + + migrationBuilder.DropColumn( + name: "ProfileKey", + table: "UAuth_UserProfiles"); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserProfiles_Tenant_UserKey", + table: "UAuth_UserProfiles", + columns: new[] { "Tenant", "UserKey" }); + } + } +} 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-shm b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm index 9c25688eed7baaff75ab914efad771e9281bd466..0c7cbb9c2724fa7c43ad5af45e889a2cd2506d70 100644 GIT binary patch delta 948 zcmb7?M@&>v6o$Xc^??#6U_>#tCev-TU>r~^ z`0N$LhKgMlCT`rhapA(9(evK8aA&;9pL5RtfA8M=@}9>N(V}*$Omt6e2GezsnV<-{P=A>SU~RBr%_*tijC|cH3w$ZJ2DM6)A$A<04mVbQ}v<##%P9 zl|2?363R91(rBe-`2?p@Po?!cl3B=d)=|VZ_Iiuca`biXN#Zlh(gk{ELn&mjf^0l& zXP-FPs;##!w?+@*jOo&`N=MOTfkrAb*&tn^;b1>UrSs{*RM?Oa%?<8bR>*j!GKYNW z^UZG=o*?5qmoTkShw(_N-xxwAe$%C6lo7*C9*F7F6PYH}t-~p`p`lb!9e{dqi+T$R zNu!p_T$Mgwx1-Dg^)Q|b7ORX{Zqr~kWXYXRNAe#qlbT^~1{vJ9BhoE{j-60mbYPs#Lpp z9iW^X;$(n|WFCuIMIoEnX;bmM2~hnx%qg4le#)y=Z*AASSRkKiijaPtz`KA~>y{sE O2J6N3>CwEG>i!Lc3;rAc delta 758 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 delta 9399 zcmeI1c~lfvy1?1#rfI5O5OE2h5Rj&zy1Kfm%ME%pLL)m8Wu1Vs1rZ`>4ALmLMMaZn zt1(ET$2B5L^h%5%(o#s_3HUGLLzW=SkknoD&ZH2Ve86?|$q3 z?ybrjvW|AKoGP@LZ-)Lj*vw31#{QT`v_3hJ@R{w21JMJQDVLY9B3M59ZrJnIDAoHP z>oJ$A$v-2aZeZ=8Vf$aKo>5g3D&mQTbW3$%TbGJw6q6qBMj!OlIuq~@s}l3Fhk?+9rP;?;;&@kIT)RSsx~XieCBXqsJ3n8-rpXbbyp zaOu>>zgliaYXYhu;@b4=htbJ}8##gx6ETTC$Bz1qU0Rdf3~8ByF{sZDKZWj03)aU z75lFDD@y^1ISgWow)4v;{?E4kR1Id&hsF9gOHP)Mf#zW4N3eogA}S@SrgIFZzzJ6H zVA^15LE**jGJPpsK?{0f>7Id6}ku_6qH-BMO8O0$(VZ`p_ zw+U{oftA4Z0gT)yXE{Bvi|&N# z4+f>`>OgxgjO=@V%i>ROx6mBo0V8oq*)0j4U?>oM0wXhD&2^Pm`j&8rCye}(?&Fic zIn@aSd<7%T@p=1}IE38ikWnzQ@3cp;$83ulklg|!0~c%!jT>THz#(E7F$)`VNhrI2 z50pQIkqu9#+?d$8Y!ru}FoH&WM=X!M0$I+ zSpB@6LngwAE@}GY@;2=QkbRy(Ou2FeOQG7*)@uM3V{I$yXWAUn>JD%JFjm7D!QDq*9@wWg4yAXx)Jvq0ms)X#HRH#U!ap`F`B3*Bx`>*q3cTi39 zV}IE0W%X|g%-;(y;6mG%6)<>@!rm`RS1TSD6nOcM=<=1L7G6-Z;@snPNX{=nPs2pZ?2hV_uf}fxYbP?|Vcpd=Jngu3 zMsjF8>sbqj^^9j7)-%3c;Xo8AELh+2Y1-&pHK~?MWJ(OBm8696ElHz{Hx;N%h0z2l zQ>!qE#OPby_cnOy>-hE$5Y8`Gk6nIWydDRMb8Tyol)e~k;b|Ggf1S8@l#0)YGjcY7 z_Bn8*+H+bq-cuL2fTv4f@wdvMYl2U_ybVO_Veyd;TphB^%MH{YSL!r0r z+olXU)e=jIvW`9h0beuR8{L8b%6^AFbc5814ZNlBPFLWCcbL#$6%s|>mTc(F-Q0|! zS1M?dVj_$ZQW=dBS}lnxHCm?1aE$^}X*Cp0DA~}94P_T>hP+m*S-|64b#`nJkcDQ*Lf{%?`vFquWf^7O7q45g|M=S{DSK$@PLzuDgGU=xKfD5q zZ^C)E;nhn+VAiX0Q2rbiH-53T((lFW&bHcg8KpoaS}93sNtImD?bNJX;ebiKI`#i__V6D9@~w_lYzCDH zc1+Xm%lf-q+?QF1se~R=ip*NJ@l@rTJ6RW7iadIZDQSK&ot+OIH?e-U5(aTSRT#wh zdW_I;Zkw&hT_TlZT0$mA88&znNky|1*<*$wc_20nFSsb`V`#GJN2`(Im#p{}U8(t)9TS3P~Se&KrU-td>VUGas zHY{#Q*xP!o>Zv_&eaVPNbZ;zWh4$#ifFv)rTCD9CnX1Kp5mPU->RIQR? z3KgnVYm`hc(n?XeOs+*K3fJNig-ofTDYjmG4MUxIK0WSzWxiZx^cGs~D}&2Y-k?8= z@((b*$Nm;YS+QXFirFs?jADz@8X>xh64{LqpuN%B-B_4?NhoW!Fn{|B{(VmqlE9>!Sjq$j7GpB%umnMhm%;kdQbf zK^4q32}-E65|qXzDpZcinMqAbOQpEXIIoG8@P@wb9;mSHI=NC5ze{OfLjGqDVPBQ? zyz2Omy8(#*EqX|F_}`vC4?H-Y?IAXZfbAgyWMOv~dFIe{HtS0s+pt}vFS+->Yd3}e zkLNhLmYZolO;INxFrNzP<1RQ|H#mDb0 zD+&F$48)v-#ci7`cTc+J=?$_k!{X|nV)CzqRd<5LcX-SpIdGada!zXIdm*~XQ*y%7 zQs2U9-JI#;#`#W&OrgYBvLtzqE;?VAjwj=JGvv{-2r8P+nwdvQld@-~&j^nU45q@y zVOmluVXkyhQccKFLZOzT3X=U(qf$#S6|H6ZQ*Q%=oE}cenA0QU5+3t09hZlBZy4G1 z)!s5t{tLYHNh9aomE5~j3=)e3%p2t%@3V$Qm5z2!^11?=hV!A$J^Wb85`WK?Uvfx~ z_gU$Z7F&mH9`7*se-PmmKHL0e&;fJy>aPVc+>YZDrqj2@@y+2rE*q=?7SCVnP?$$& zr1{}F8uFd#nc{cnOp|7&=8MA^->JkoIhb6Nlo6>>P0Gm(2}w>TW`w?{oS7_%Qu_sF ziswk8Q)f%aX}a8Tlv<-ES!dxS)5w{FxCE7})8Ai3cu)|-!e5dh9X?Rh zuz0_(&t?0dS-Zf>GFUuhyjJt*a^3~wInvRKNg>lsIR5;ooAcqfzGmj8V zf3^n*!Tl@z?-0YLEBtY$Ma@%hIQc{)*a~|AxT+ z3#&qJt{i!~9f-=|M}7NZ%fe?io^6~*bg)MzxYp^{L*fO;4SYW}7DyV{GHAg0M~ zE;6iFk7Su!y9Gz7j92fsciXv3doQ`}Q1@8siW@sm7ntoV0@>}b-Vr-TA9LSlz6dPt zfW>C5D-R7%Tz_d#xd3u#Mv`uQtRik9*e4PQ{@o%ipha%KYUlks*z(u5TAz=mc>f(V hxx&lyn7G9+^6P*%zzqx*FLVTa%h+r4wkQOme*ns9@7DkT 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.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/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/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/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/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs index 3034f685..8f941e55 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs @@ -14,9 +14,13 @@ public interface IUserClient Task DeleteMeAsync(); Task> DeleteUserAsync(UserKey userKey, DeleteUserRequest request); - Task> GetMeAsync(); + Task> GetMeAsync(GetProfileRequest? request = null); Task UpdateMeAsync(UpdateProfileRequest request); + Task CreateMyProfileAsync(CreateProfileRequest request); + Task DeleteMyProfileAsync(ProfileKey profileKey); - Task> GetUserAsync(UserKey userKey); + Task> GetUserAsync(UserKey userKey, GetProfileRequest? request = null); Task UpdateUserAsync(UserKey userKey, UpdateProfileRequest request); + Task CreateUserProfileAsync(UserKey userKey, CreateProfileRequest request); + Task DeleteUserProfileAsync(UserKey userKey, ProfileKey profileKey); } 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/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/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/EfCoreUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs index 9623dc92..963d4dec 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; @@ -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/InMemoryUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs index 38195576..bb683ffd 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) { } + 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.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/UserProfileTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserProfileTests.cs new file mode 100644 index 00000000..671f0344 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserProfileTests.cs @@ -0,0 +1,213 @@ +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 cookie = login.Headers.GetValues("Set-Cookie").First(); + _client.DefaultRequestHeaders.Add("Cookie", cookie); + + var request = new CreateProfileRequest + { + ProfileKey = ProfileKey.Parse("business", null) + }; + + 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/EfCoreUserProfileStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserProfileStoreTests.cs index 87d84078..69b79f4f 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; @@ -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); @@ -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", @@ -98,7 +102,7 @@ public async Task Save_Should_Increment_Version() await using (var db2 = CreateDb(connection)) { var store2 = new EfCoreUserProfileStore(db2, new TenantContext(tenant)); - var existing = await store2.GetAsync(new UserProfileKey(tenant, userKey)); + 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); } @@ -106,7 +110,7 @@ public async Task Save_Should_Increment_Version() await using (var db3 = CreateDb(connection)) { var store3 = new EfCoreUserProfileStore(db3, new TenantContext(tenant)); - var result = await store3.GetAsync(new UserProfileKey(tenant, userKey)); + 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", @@ -140,7 +145,7 @@ public async Task Save_With_Wrong_Version_Should_Throw() await using (var db2 = CreateDb(connection)) { var store2 = new EfCoreUserProfileStore(db2, new TenantContext(tenant)); - var existing = await store2.GetAsync(new UserProfileKey(tenant, userKey)); + 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(() => @@ -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); } @@ -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 TenantContext(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 TenantContext(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 TenantContext(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 TenantContext(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 TenantContext(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); + } } From ce4e4c3f8cb1715303507dd1fb788e776e0cffa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 6 Apr 2026 02:45:16 +0300 Subject: [PATCH 4/5] Fix Test --- .../UserProfileTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserProfileTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserProfileTests.cs index 671f0344..4859ea70 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserProfileTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserProfileTests.cs @@ -119,12 +119,14 @@ 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 = ProfileKey.Parse("business", null) + ProfileKey = key }; var first = await _client.PostAsJsonAsync("/auth/me/profile/create", request); From e3cd617bb1a8bb5c2f40c9b36f5a262393930a98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 6 Apr 2026 11:16:33 +0300 Subject: [PATCH 5/5] Fix Warnings & Added Refresh Integration Tests --- .../Contracts/Pkce/PkceCompleteRequest.cs | 4 +- .../Infrastructure/SessionValidationMapper.cs | 2 +- .../AspNetCore/UAuthPolicyProvider.cs | 4 +- .../ResourceApi/ResourceAuthContextFactory.cs | 12 +- .../Services/UAuthFlowClient.cs | 2 +- .../RefreshTests.cs | 200 ++++++++++++++++++ 6 files changed, 214 insertions(+), 10 deletions(-) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs 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/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.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/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/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/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); + } +}