From 6208e7608e2338638d62d0f8f898a5e86b6e3b1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 2 Nov 2026 00:16:10 +0300 Subject: [PATCH 01/10] Add Password Policy and Password History --- .../Extensions/ServiceCollectionExtensions.cs | 5 + .../Options/UAuthServerOptions.cs | 8 + .../Dtos/PasswordPolicyErrorCodes.cs | 20 ++ .../Dtos/PasswordPolicyLevel.cs | 12 + .../Dtos/PasswordPolicyViolation.cs | 3 + .../Responses/PasswordPolicyResult.cs | 18 ++ .../Data/UAuthCredentialDbContext.cs | 1 + .../Data/UAuthCredentialsModelBuilder.cs | 37 +++ .../Extensions/ServiceCollectionExtensions.cs | 1 + .../Projections/PasswordHistoryProjection.cs | 19 ++ .../Stores/EfCorePasswordHistoryStore.cs | 113 ++++++++ .../EfCorePasswordHistoryStoreFactory.cs | 13 + .../ServiceCollectionExtensions.cs | 4 +- .../InMemoryPasswordCredentialStore.cs | 0 .../InMemoryPasswordCredentialStoreFactory.cs | 0 .../Stores/InMemoryPasswordHistoryStore.cs | 131 +++++++++ .../InMemoryPasswordHistoryStoreFactory.cs | 16 ++ .../Domain/PasswordHistoryEntry.cs | 20 ++ .../Extensions/ServiceCollectionExtensions.cs | 4 + .../PasswordUserLifecycleIntegration.cs | 36 ++- .../DefaultPasswordHistoryValidator.cs | 45 ++++ .../DefaultPasswordValidationPipeline.cs | 40 +++ .../Policies/IPasswordHistoryValidator.cs | 8 + .../Policies/IPasswordValidationPipeline.cs | 8 + .../Policies/PasswordValidationRequest.cs | 12 + .../Services/CredentialManagementService.cs | 227 ++++++++++------ .../Services/PasswordCredentialWriter.cs | 102 +++++++ .../Stores/IPasswordHistoryStore.cs | 16 ++ .../Stores/IPasswordHistoryStoreFactory.cs | 8 + ...sswordPolicyServiceCollectionExtensions.cs | 48 ++++ .../Options/EffectivePasswordPolicyOptions.cs | 22 ++ .../Options/PasswordHistoryOptions.cs | 15 ++ .../Options/PasswordPolicyLimits.cs | 11 + .../Options/PasswordPolicyOptions.cs | 37 +++ .../Options/PasswordPolicyOptionsResolver.cs | 93 +++++++ .../Options/PasswordPolicyOptionsValidator.cs | 82 ++++++ .../Policies/BuiltInCommonPasswordChecker.cs | 130 +++++++++ .../DefaultPasswordPolicyContextProvider.cs | 17 ++ .../Policies/DefaultPasswordStrengthPolicy.cs | 106 ++++++++ .../DefaultPasswordUserSimilarityChecker.cs | 113 ++++++++ .../IPasswordCommonPasswordChecker.cs | 8 + .../IPasswordPolicyContextProvider.cs | 9 + .../Policies/IPasswordStrengthPolicy.cs | 9 + .../IPasswordUserSimilarityChecker.cs | 8 + .../Policies/PasswordPolicyContext.cs | 10 + .../Extensions/ServiceCollectonExtensions.cs | 8 +- .../UserPasswordPolicyContextProvider.cs | 39 +++ .../CredentialManagementServiceTests.cs | 254 ++++++++++++++++-- .../DefaultPasswordStrengthPolicyTests.cs | 124 +++++++++ .../Credentials/PasswordPolicyOptionsTests.cs | 116 ++++++++ .../PasswordValidationPipelineTests.cs | 74 +++++ 51 files changed, 2140 insertions(+), 120 deletions(-) create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyErrorCodes.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyLevel.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyViolation.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/PasswordPolicyResult.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordHistoryProjection.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordHistoryStore.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordHistoryStoreFactory.cs rename src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/{ => Extensions}/ServiceCollectionExtensions.cs (79%) rename src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/{ => Stores}/InMemoryPasswordCredentialStore.cs (100%) rename src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/{ => Stores}/InMemoryPasswordCredentialStoreFactory.cs (100%) create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Stores/InMemoryPasswordHistoryStore.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Stores/InMemoryPasswordHistoryStoreFactory.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordHistoryEntry.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/DefaultPasswordHistoryValidator.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/DefaultPasswordValidationPipeline.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/IPasswordHistoryValidator.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/IPasswordValidationPipeline.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/PasswordValidationRequest.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/PasswordCredentialWriter.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Stores/IPasswordHistoryStore.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Stores/IPasswordHistoryStoreFactory.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Extensions/PasswordPolicyServiceCollectionExtensions.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Options/EffectivePasswordPolicyOptions.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordHistoryOptions.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyLimits.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyOptions.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyOptionsResolver.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyOptionsValidator.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/BuiltInCommonPasswordChecker.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/DefaultPasswordPolicyContextProvider.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/DefaultPasswordStrengthPolicy.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/DefaultPasswordUserSimilarityChecker.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordCommonPasswordChecker.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordPolicyContextProvider.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordStrengthPolicy.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordUserSimilarityChecker.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/PasswordPolicyContext.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserPasswordPolicyContextProvider.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/DefaultPasswordStrengthPolicyTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordPolicyOptionsTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordValidationPipelineTests.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index 572cf606..f67c241c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -36,6 +36,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; +using CodeBeam.UltimateAuth.Credentials.Extensions; namespace CodeBeam.UltimateAuth.Server.Extensions; @@ -371,6 +372,10 @@ internal static IServiceCollection AddCredentialsInternal(IServiceCollection ser { services.TryAddScoped(); services.TryAddScoped(); + + services.AddPasswordStrengthPolicy(); + services.AddPasswordPolicyOptions(); + return services; } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index c5474e56..ed510713 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core; using CodeBeam.UltimateAuth.Core.Events; using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Credentials.Options; using Microsoft.AspNetCore.Routing; namespace CodeBeam.UltimateAuth.Server.Options; @@ -89,6 +90,12 @@ public sealed class UAuthServerOptions /// public UAuthCredentialSessionPolicyOptions CredentialSessionPolicy { get; init; } = new(); + /// + /// Controls password strength, common-password detection, + /// user similarity checks, and password reuse history. + /// + public PasswordPolicyOptions Password { get; set; } = new(); + public UAuthHubOptions Hub { get; set; } = new(); /// @@ -162,6 +169,7 @@ internal UAuthServerOptions Clone() AuthResponse = AuthResponse.Clone(), ResetCredential = ResetCredential.Clone(), CredentialSessionPolicy = CredentialSessionPolicy.Clone(), + Password = Password.Clone(), Hub = Hub.Clone(), SessionResolution = SessionResolution.Clone(), Identifiers = Identifiers.Clone(), diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyErrorCodes.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyErrorCodes.cs new file mode 100644 index 00000000..d1ae35bf --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyErrorCodes.cs @@ -0,0 +1,20 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public static class PasswordPolicyErrorCodes +{ + public const string Required = "password_required"; + public const string TooShort = "password_too_short"; + public const string TooLong = "password_too_long"; + + public const string LetterRequired = "password_letter_required"; + public const string DigitRequired = "password_digit_required"; + public const string UppercaseRequired = "password_uppercase_required"; + public const string LowercaseRequired = "password_lowercase_required"; + public const string SpecialCharacterRequired = "password_special_character_required"; + + public const string CommonPassword = "password_too_common"; + public const string SimilarToUser = "password_similar_to_user"; + + public const string ReusedPassword = "password_reused"; + public const string InputTooLong = "password_input_too_long"; +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyLevel.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyLevel.cs new file mode 100644 index 00000000..dbf5457f --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyLevel.cs @@ -0,0 +1,12 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +/// +/// Defines the scope of password policy enforcement. +/// +public enum PasswordPolicyLevel +{ + Disabled = 0, + Basic = 10, + Standard = 20, + Strict = 30 +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyViolation.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyViolation.cs new file mode 100644 index 00000000..3b8b1efa --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyViolation.cs @@ -0,0 +1,3 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record PasswordPolicyViolation(string Code, string Message); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/PasswordPolicyResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/PasswordPolicyResult.cs new file mode 100644 index 00000000..9473fdc4 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/PasswordPolicyResult.cs @@ -0,0 +1,18 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed class PasswordPolicyResult +{ + public bool Succeeded => Violations.Count == 0; + + public IReadOnlyList Violations { get; } + + public PasswordPolicyResult(IEnumerable violations) + { + ArgumentNullException.ThrowIfNull(violations); + Violations = Array.AsReadOnly(violations.ToArray()); + } + + public static PasswordPolicyResult Success() => new(Array.Empty()); + + public static PasswordPolicyResult Failure(params PasswordPolicyViolation[] violations) => new(violations); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialDbContext.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialDbContext.cs index fb5a975d..bf897e3d 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialDbContext.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialDbContext.cs @@ -5,6 +5,7 @@ namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; public sealed class UAuthCredentialDbContext : DbContext { public DbSet PasswordCredentials => Set(); + public DbSet PasswordHistory => Set(); public UAuthCredentialDbContext(DbContextOptions options) : base(options) diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialsModelBuilder.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialsModelBuilder.cs index 5b1b3ab5..b0da5a05 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialsModelBuilder.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialsModelBuilder.cs @@ -11,6 +11,7 @@ public static class UAuthCredentialsModelBuilder public static void Configure(ModelBuilder b) { ConfigurePasswordCredentials(b); + ConfigurePasswordHistory(b); } private static void ConfigurePasswordCredentials(ModelBuilder b) @@ -61,4 +62,40 @@ private static void ConfigurePasswordCredentials(ModelBuilder b) e.HasIndex(x => new { x.Tenant, x.ExpiresAt }); }); } + + private static void ConfigurePasswordHistory(ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("UAuth_PasswordHistory"); + + e.HasKey(x => x.Id); + + e.Property(x => x.Tenant) + .HasConversion(v => v.Value, v => TenantKey.FromInternal(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.UserKey) + .HasConversion(v => v.Value, v => UserKey.FromString(v)) + .HasMaxLength(128) + .IsRequired(); + + e.HasIndex(x => new { x.Tenant, x.UserKey }); + + e.Property(x => x.CredentialId) + .IsRequired(); + + e.Property(x => x.SecretHash) + .HasConversion(v => v.ToString(), v => PasswordHash.Parse(v, null)) + .HasMaxLength(512) + .IsRequired(); + + e.Property(x => x.ReplacedAt) + .HasUtcDateTimeOffsetConverter() + .IsRequired(); + + e.HasIndex(x => new { x.Tenant, x.CredentialId, x.ReplacedAt, x.Id }); + }); + } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs index 2925ccb8..93200a20 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -17,6 +17,7 @@ public static IServiceCollection AddUltimateAuthCredentialsEntityFrameworkCore(); services.AddScoped>(); + services.AddScoped>(); return services; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordHistoryProjection.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordHistoryProjection.cs new file mode 100644 index 00000000..4f1b5c35 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordHistoryProjection.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +public sealed class PasswordHistoryProjection +{ + public Guid Id { get; set; } + + public TenantKey Tenant { get; set; } + public UserKey UserKey { get; set; } + + public Guid CredentialId { get; set; } + + public PasswordHash SecretHash { get; set; } + + public DateTimeOffset ReplacedAt { get; set; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordHistoryStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordHistoryStore.cs new file mode 100644 index 00000000..40d287d8 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordHistoryStore.cs @@ -0,0 +1,113 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +internal sealed class EfCorePasswordHistoryStore(TDbContext db, TenantKey tenant) : IPasswordHistoryStore where TDbContext : DbContext +{ + private DbSet DbSet => db.Set(); + + public async Task> GetRecentAsync(CredentialKey credentialKey, int count, CancellationToken ct = default) + { + ValidateKey(credentialKey); + + if (count < 0) + throw new ArgumentOutOfRangeException(nameof(count)); + + var entries = await DbSet + .AsNoTracking() + .Where(x => x.Tenant == tenant && x.CredentialId == credentialKey.Id) + .OrderByDescending(x => x.ReplacedAt) + .ThenByDescending(x => x.Id) + .Take(count) + .ToListAsync(ct); + + return entries.Select(x => new PasswordHistoryEntry + { + Id = x.Id, + Tenant = x.Tenant, + UserKey = x.UserKey, + CredentialKey = new CredentialKey(x.Tenant, x.CredentialId), + SecretHash = x.SecretHash, + ReplacedAt = x.ReplacedAt + }).ToArray(); + } + + public async Task AddAsync(PasswordHistoryEntry entry, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(entry); + ValidateKey(entry.CredentialKey); + + if (entry.Tenant != tenant) + throw new UAuthConflictException("tenant_mismatch"); + + if (entry.Id == Guid.Empty) + throw new ArgumentException("History entry ID is required."); + + DbSet.Add(new PasswordHistoryProjection + { + Id = entry.Id, + Tenant = entry.Tenant, + UserKey = entry.UserKey, + CredentialId = entry.CredentialKey.Id, + SecretHash = entry.SecretHash, + ReplacedAt = entry.ReplacedAt + }); + + await db.SaveChangesAsync(ct); + } + + public async Task PruneAsync(CredentialKey credentialKey, int keepLast, CancellationToken ct = default) + { + ValidateKey(credentialKey); + + if (keepLast < 0) + throw new ArgumentOutOfRangeException(nameof(keepLast)); + + var keepIds = await DbSet + .AsNoTracking() + .Where(x => x.Tenant == tenant && x.CredentialId == credentialKey.Id) + .OrderByDescending(x => x.ReplacedAt) + .ThenByDescending(x => x.Id) + .Take(keepLast) + .Select(x => x.Id) + .ToListAsync(ct); + + await DbSet + .Where(x => + x.Tenant == tenant && + x.CredentialId == credentialKey.Id && + !keepIds.Contains(x.Id)) + .ExecuteDeleteAsync(ct); + } + + public async Task DeleteByCredentialAsync(CredentialKey credentialKey, CancellationToken ct = default) + { + ValidateKey(credentialKey); + + await DbSet + .Where(x => + x.Tenant == tenant && + x.CredentialId == credentialKey.Id) + .ExecuteDeleteAsync(ct); + } + + public async Task DeleteByUserAsync(UserKey userKey, CancellationToken ct = default) + { + await DbSet + .Where(x => + x.Tenant == tenant && + x.UserKey == userKey) + .ExecuteDeleteAsync(ct); + } + + private void ValidateKey(CredentialKey key) + { + if (key.Tenant != tenant) + throw new UAuthConflictException("tenant_mismatch"); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordHistoryStoreFactory.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordHistoryStoreFactory.cs new file mode 100644 index 00000000..1f8d2b32 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordHistoryStoreFactory.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Reference; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +internal sealed class EfCorePasswordHistoryStoreFactory(TDbContext db) : IPasswordHistoryStoreFactory where TDbContext : DbContext +{ + public IPasswordHistoryStore Create(TenantKey tenant) + { + return new EfCorePasswordHistoryStore(db, tenant); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Extensions/ServiceCollectionExtensions.cs similarity index 79% rename from src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs rename to src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Extensions/ServiceCollectionExtensions.cs index 46acfd8f..613253e1 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.InMemory.Extensions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -13,6 +12,7 @@ public static IServiceCollection AddUltimateAuthCredentialsInMemory(this IServic services.AddUltimateAuthInMemoryInfrastructure(); services.TryAddSingleton(); + services.TryAddSingleton(); return services; } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Stores/InMemoryPasswordCredentialStore.cs similarity index 100% rename from src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs rename to src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Stores/InMemoryPasswordCredentialStore.cs diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStoreFactory.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Stores/InMemoryPasswordCredentialStoreFactory.cs similarity index 100% rename from src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStoreFactory.cs rename to src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Stores/InMemoryPasswordCredentialStoreFactory.cs diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Stores/InMemoryPasswordHistoryStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Stores/InMemoryPasswordHistoryStore.cs new file mode 100644 index 00000000..3b98017c --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Stores/InMemoryPasswordHistoryStore.cs @@ -0,0 +1,131 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.InMemory; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Credentials.InMemory; + +internal sealed class InMemoryPasswordHistoryStore(TenantKey tenant, InMemoryAtomicContextAccessor atomicContext) : IPasswordHistoryStore +{ + private readonly ConcurrentDictionary _entries = new(); + + public Task> GetRecentAsync(CredentialKey credentialKey, int count, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + ValidateKey(credentialKey); + + if (count < 0) + throw new ArgumentOutOfRangeException(nameof(count)); + + IReadOnlyList result = _entries.Values + .Where(x => x.CredentialKey == credentialKey) + .OrderByDescending(x => x.ReplacedAt) + .ThenByDescending(x => x.Id) + .Take(count) + .ToArray(); + + return Task.FromResult(result); + } + + public Task AddAsync(PasswordHistoryEntry entry, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(entry); + + ValidateKey(entry.CredentialKey); + + if (entry.Tenant != tenant) + throw new UAuthConflictException("tenant_mismatch"); + + if (entry.Id == Guid.Empty) + throw new ArgumentException("History entry ID is required."); + + if (!_entries.TryAdd(entry.Id, entry)) + throw new UAuthConflictException("password_history_exists"); + + atomicContext.Current?.RegisterRollback(() => + { + if (!_entries.TryRemove(new KeyValuePair(entry.Id, entry))) + { + throw new InvalidOperationException("Password history rollback failed."); + } + }); + + return Task.CompletedTask; + } + + public Task PruneAsync(CredentialKey credentialKey, int keepLast, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + ValidateKey(credentialKey); + + if (keepLast < 0) + throw new ArgumentOutOfRangeException(nameof(keepLast)); + + var remove = _entries.Values + .Where(x => x.CredentialKey == credentialKey) + .OrderByDescending(x => x.ReplacedAt) + .ThenByDescending(x => x.Id) + .Skip(keepLast) + .ToArray(); + + RemoveEntries(remove); + + return Task.CompletedTask; + } + + public Task DeleteByCredentialAsync(CredentialKey credentialKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + ValidateKey(credentialKey); + + var remove = _entries.Values + .Where(x => x.CredentialKey == credentialKey) + .ToArray(); + + RemoveEntries(remove); + + return Task.CompletedTask; + } + + public Task DeleteByUserAsync(UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var entries = _entries.Values + .Where(x => x.UserKey == userKey) + .ToArray(); + + RemoveEntries(entries); + + return Task.CompletedTask; + } + + private void RemoveEntries(IReadOnlyCollection entries) + { + foreach (var entry in entries) + { + if (!_entries.TryRemove(new KeyValuePair(entry.Id, entry))) + { + throw new InvalidOperationException("Password history entry changed concurrently."); + } + + atomicContext.Current?.RegisterRollback(() => + { + if (!_entries.TryAdd(entry.Id, entry)) + { + throw new InvalidOperationException("Password history rollback failed."); + } + }); + } + } + + private void ValidateKey(CredentialKey key) + { + if (key.Tenant != tenant) + throw new UAuthConflictException("tenant_mismatch"); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Stores/InMemoryPasswordHistoryStoreFactory.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Stores/InMemoryPasswordHistoryStoreFactory.cs new file mode 100644 index 00000000..0c3181cd --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Stores/InMemoryPasswordHistoryStoreFactory.cs @@ -0,0 +1,16 @@ +using System.Collections.Concurrent; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.InMemory; + +namespace CodeBeam.UltimateAuth.Credentials.InMemory; + +public sealed class InMemoryPasswordHistoryStoreFactory(InMemoryAtomicContextAccessor atomicContext) : IPasswordHistoryStoreFactory +{ + private readonly ConcurrentDictionary _stores = new(); + + public IPasswordHistoryStore Create(TenantKey tenant) + { + return _stores.GetOrAdd(tenant, t => new InMemoryPasswordHistoryStore(t, atomicContext)); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordHistoryEntry.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordHistoryEntry.cs new file mode 100644 index 00000000..25107f45 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordHistoryEntry.cs @@ -0,0 +1,20 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public sealed record PasswordHistoryEntry +{ + public required Guid Id { get; init; } + + public required TenantKey Tenant { get; init; } + public required UserKey UserKey { get; init; } + + public required CredentialKey CredentialKey { get; init; } + + public required PasswordHash SecretHash { get; init; } + + public required DateTimeOffset ReplacedAt { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs index b7c8ae8a..f7502d48 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs @@ -15,6 +15,10 @@ public static IServiceCollection AddUltimateAuthCredentialsReference(this IServi services.TryAddScoped(); services.TryAddScoped(); services.AddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + return services; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs index 918ea9c4..13c3c5dd 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs @@ -3,6 +3,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Policies; using CodeBeam.UltimateAuth.Users; using CodeBeam.UltimateAuth.Users.Contracts; @@ -12,12 +13,18 @@ internal sealed class PasswordUserLifecycleIntegration : IUserLifecycleIntegrati { private readonly IPasswordCredentialStoreFactory _credentialStoreFactory; private readonly IUAuthPasswordHasher _passwordHasher; + private readonly PasswordCredentialWriter _passwordWriter; + private readonly IPasswordHistoryStoreFactory _historyStores; + private readonly IPasswordPolicyContextProvider _passwordContextProvider; private readonly IClock _clock; - public PasswordUserLifecycleIntegration(IPasswordCredentialStoreFactory credentialStoreFactory, IUAuthPasswordHasher passwordHasher, IClock clock) + public PasswordUserLifecycleIntegration(IPasswordCredentialStoreFactory credentialStoreFactory, IUAuthPasswordHasher passwordHasher, PasswordCredentialWriter passwordWriter, IPasswordHistoryStoreFactory historyStores, IPasswordPolicyContextProvider passwordContextProvider, IClock clock) { _credentialStoreFactory = credentialStoreFactory; _passwordHasher = passwordHasher; + _passwordWriter = passwordWriter; + _historyStores = historyStores; + _passwordContextProvider = passwordContextProvider; _clock = clock; } @@ -29,24 +36,25 @@ public async Task OnUserCreatedAsync(TenantKey tenant, UserKey userKey, object r if (string.IsNullOrWhiteSpace(r.Password)) return; - var hash = _passwordHasher.Hash(r.Password); - - var credential = PasswordCredential.Create( - id: null, - tenant: tenant, - userKey: userKey, - secretHash: hash, - security: CredentialSecurityState.Active(), - metadata: new CredentialMetadata { }, - _clock.UtcNow); - - var credentialStore = _credentialStoreFactory.Create(tenant); - await credentialStore.AddAsync(credential, ct); + var passwordContext = await _passwordContextProvider.CreateAsync(tenant, userKey, r.Password, ct); + await _passwordWriter.CreateAsync( + tenant, + userKey, + r.Password, + passwordContext, + _clock.UtcNow, + ct); } public async Task OnUserDeletedAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, CancellationToken ct) { var credentialStore = _credentialStoreFactory.Create(tenant); + await credentialStore.DeleteByUserAsync(userKey, mode, _clock.UtcNow, ct); + + if (mode == DeleteMode.Hard) + { + await _historyStores.Create(tenant).DeleteByUserAsync(userKey, ct); + } } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/DefaultPasswordHistoryValidator.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/DefaultPasswordHistoryValidator.cs new file mode 100644 index 00000000..6c7f9141 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/DefaultPasswordHistoryValidator.cs @@ -0,0 +1,45 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public sealed class DefaultPasswordHistoryValidator(IPasswordCredentialStoreFactory credentialStoreFactory, IPasswordHistoryStoreFactory historyStoreFactory, IUAuthPasswordHasher hasher) : IPasswordHistoryValidator +{ + public async Task ValidateAsync(CredentialKey credentialKey, string password, int rememberLast, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (rememberLast is < 0 or > 10) + throw new ArgumentOutOfRangeException(nameof(rememberLast)); + + var credentialStore = credentialStoreFactory.Create(credentialKey.Tenant); + + var credential = await credentialStore.GetAsync(credentialKey, ct); + + if (credential is null) + throw new InvalidOperationException("Password credential was not found."); + + if (hasher.Verify(credential.SecretHash, password)) + return Reused(); + + if (rememberLast == 0) + return PasswordPolicyResult.Success(); + + var historyStore = historyStoreFactory.Create(credentialKey.Tenant); + var previous = await historyStore.GetRecentAsync(credentialKey, rememberLast, ct); + + foreach (var entry in previous) + { + ct.ThrowIfCancellationRequested(); + + if (hasher.Verify(entry.SecretHash, password)) + return Reused(); + } + + return PasswordPolicyResult.Success(); + } + + private static PasswordPolicyResult Reused() + => PasswordPolicyResult.Failure(new PasswordPolicyViolation(PasswordPolicyErrorCodes.ReusedPassword, "Password was used previously.")); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/DefaultPasswordValidationPipeline.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/DefaultPasswordValidationPipeline.cs new file mode 100644 index 00000000..92c2669f --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/DefaultPasswordValidationPipeline.cs @@ -0,0 +1,40 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; +using CodeBeam.UltimateAuth.Credentials.Policies; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public sealed class DefaultPasswordValidationPipeline(IPasswordStrengthPolicy strengthPolicy, IPasswordHistoryValidator historyValidator, EffectivePasswordPolicyOptions options) : IPasswordValidationPipeline +{ + public async Task ValidateAsync(PasswordValidationRequest request, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(request.Context); + + ct.ThrowIfCancellationRequested(); + + var password = request.Context.Password; + + if (password is null) + { + return PasswordPolicyResult.Failure(new PasswordPolicyViolation(PasswordPolicyErrorCodes.Required, "Password is required.")); + } + + // Mandatory guard, independent of custom strength policies. + if (password.Length > PasswordPolicyLimits.AbsoluteMaxLength) + { + return PasswordPolicyResult.Failure(new PasswordPolicyViolation(PasswordPolicyErrorCodes.InputTooLong, "Password exceeds the absolute length limit.")); + } + + var strength = await strengthPolicy.ValidateAsync(request.Context, options, ct); + var violations = new List(strength.Violations); + + if (request.ExistingCredentialKey is { } credentialKey) + { + var history = await historyValidator.ValidateAsync(credentialKey, password, options.RememberLast, ct); + violations.AddRange(history.Violations); + } + + return new PasswordPolicyResult(violations); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/IPasswordHistoryValidator.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/IPasswordHistoryValidator.cs new file mode 100644 index 00000000..7d6d6402 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/IPasswordHistoryValidator.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public interface IPasswordHistoryValidator +{ + Task ValidateAsync(CredentialKey credentialKey, string password, int rememberLast, CancellationToken ct = default); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/IPasswordValidationPipeline.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/IPasswordValidationPipeline.cs new file mode 100644 index 00000000..27041fd9 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/IPasswordValidationPipeline.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public interface IPasswordValidationPipeline +{ + Task ValidateAsync(PasswordValidationRequest request, CancellationToken ct = default); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/PasswordValidationRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/PasswordValidationRequest.cs new file mode 100644 index 00000000..cab106f7 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/PasswordValidationRequest.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Policies; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public sealed record PasswordValidationRequest +{ + public required PasswordPolicyContext Context { get; init; } + + // Null for first-time credential creation. + public CredentialKey? ExistingCredentialKey { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs index 7118a076..01244d5a 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -5,6 +5,7 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Policies; using CodeBeam.UltimateAuth.Credentials.Reference.Internal; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; @@ -28,6 +29,9 @@ internal sealed class CredentialManagementService : ICredentialManagementService private readonly ICredentialResetNotifier _resetNotifier; private readonly ICredentialSessionPolicy _credentialSessionPolicy; private readonly IUAuthAtomicExecutor _atomicExecutor; + private readonly PasswordCredentialWriter _passwordWriter; + private readonly IPasswordHistoryStoreFactory _historyStores; + private readonly IPasswordPolicyContextProvider _passwordContextProvider; private readonly UAuthServerOptions _options; private readonly IClock _clock; @@ -44,6 +48,9 @@ public CredentialManagementService( ICredentialResetNotifier resetNotifier, ICredentialSessionPolicy credentialSessionPolicy, IUAuthAtomicExecutor atomicExecutor, + PasswordCredentialWriter passwordWriter, + IPasswordHistoryStoreFactory historyStores, + IPasswordPolicyContextProvider passwordContextProvider, IOptions options, IClock clock) { @@ -59,6 +66,9 @@ public CredentialManagementService( _resetNotifier = resetNotifier; _credentialSessionPolicy = credentialSessionPolicy; _atomicExecutor = atomicExecutor; + _passwordWriter = passwordWriter; + _historyStores = historyStores; + _passwordContextProvider = passwordContextProvider; _options = options.Value; _clock = clock; } @@ -99,27 +109,24 @@ public async Task AddAsync(AccessContext context, AddCreden { ct.ThrowIfCancellationRequested(); - var cmd = new AccessCommand(async innerCt => - { - var subjectUser = context.GetTargetUserKey(); - var now = _clock.UtcNow; - - var hash = _hasher.Hash(request.Secret); - - var credential = PasswordCredential.Create( - id: null, - tenant: context.ResourceTenant, - userKey: subjectUser, - secretHash: hash, - security: CredentialSecurityState.Active(), - metadata: new CredentialMetadata(), - now: now); + var cmd = new AccessCommand( + innerCt => _atomicExecutor.ExecuteAsync( + async atomicCt => + { + var subjectUser = context.GetTargetUserKey(); + var passwordContext = await _passwordContextProvider.CreateAsync(context.ResourceTenant, subjectUser, request.Secret, atomicCt); - var store = _credentialsFactory.Create(context.ResourceTenant); - await store.AddAsync(credential, innerCt); + var credential = await _passwordWriter.CreateAsync( + context.ResourceTenant, + subjectUser, + request.Secret, + passwordContext, + _clock.UtcNow, + atomicCt); - return CredentialAddResult.Success(credential.Id, credential.Type); - }); + return CredentialAddResult.Success(credential.Id, credential.Type); + }, + innerCt)); return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } @@ -156,13 +163,8 @@ public async Task ChangeSecretAsync(AccessContext contex throw new UAuthConflictException("invalid_credentials"); } - if (_hasher.Verify(pwd.SecretHash, request.NewSecret)) - throw new UAuthValidationException("credential_secret_same"); - - var oldVersion = pwd.Version; - var newHash = _hasher.Hash(request.NewSecret); - var updated = pwd.ChangeSecret(newHash, now); - await store.SaveAsync(updated, oldVersion, atomicCt); + var passwordContext = await _passwordContextProvider.CreateAsync(context.ResourceTenant, subjectUser, request.NewSecret, atomicCt); + await _passwordWriter.ChangeAsync(pwd, request.NewSecret, passwordContext, now, atomicCt); await ApplyCredentialSessionPolicyAsync(context.ResourceTenant, subjectUser, context.IsSelfAction ? CredentialChangeReason.SelfChange @@ -304,21 +306,21 @@ public async Task CompleteResetAsync(AccessContext conte var resolution = await _identifierResolver.ResolveAsync(context.ResourceTenant, request.Identifier, innerCt); if (context.IsAdminContext && resolution?.UserKey != context.GetTargetUserKey()) + { throw new UAuthValidationException("credential_target_mismatch"); + } if (resolution?.UserKey is not UserKey userKey) - { - // Enumeration protection return CredentialActionResult.Success(); - } - var state = await _authenticationSecurityManager.GetOrCreateFactorAsync(context.ResourceTenant, userKey, request.CredentialType, innerCt); + var state = await _authenticationSecurityManager + .GetOrCreateFactorAsync(context.ResourceTenant, userKey, request.CredentialType, innerCt); if (state.IsResetExpired(now)) { - var version2 = state.SecurityVersion; var cleared = state.ClearReset(); - await _authenticationSecurityManager.UpdateAsync(cleared, version2, innerCt); + + await _authenticationSecurityManager.UpdateAsync(cleared, state.SecurityVersion, innerCt); if (context.IsAnonymousContext) return CredentialActionResult.Success(); @@ -336,9 +338,9 @@ public async Task CompleteResetAsync(AccessContext conte if (!_tokenHasher.Verify(state.ResetTokenHash!, request.ResetToken)) { - var version = state.SecurityVersion; var failed = state.RegisterResetFailure(now, _options.ResetCredential.MaxAttempts); - await _authenticationSecurityManager.UpdateAsync(failed, version, innerCt); + + await _authenticationSecurityManager.UpdateAsync(failed, state.SecurityVersion, innerCt); if (context.IsAnonymousContext) return CredentialActionResult.Success(); @@ -346,8 +348,14 @@ public async Task CompleteResetAsync(AccessContext conte throw new UAuthConflictException("invalid_reset_token"); } + // A valid reset proof has now been established. + // Password validation errors may be returned without + // disclosing whether an unverified account exists. + var store = _credentialsFactory.Create(context.ResourceTenant); + var credentials = await store.GetByUserAsync(userKey, innerCt); + var pwd = credentials.OfType().FirstOrDefault(c => c.Security.IsUsable(now)); if (pwd is null) @@ -358,42 +366,71 @@ public async Task CompleteResetAsync(AccessContext conte throw new UAuthNotFoundException("credential_not_found"); } - if (_hasher.Verify(pwd.SecretHash, request.NewSecret)) - { - if (!context.IsAnonymousContext) - throw new UAuthValidationException("credential_secret_same"); - - // Valid reset token, but password is unchanged. - // Consume the token to preserve single-use semantics. - var version = state.SecurityVersion; - var consumed = state.ConsumeReset(now); - - await _authenticationSecurityManager.UpdateAsync( - consumed, - version, - innerCt); - - return CredentialActionResult.Success(); - } - - return await _atomicExecutor.ExecuteAsync(async atomicCt => - { - var version = state.SecurityVersion; - var consumed = state.ConsumeReset(now); - - await _authenticationSecurityManager.UpdateAsync(consumed, version, atomicCt); - - var oldVersion = pwd.Version; - var newHash = _hasher.Hash(request.NewSecret); - var updated = pwd.ChangeSecret(newHash, now); - - await store.SaveAsync(updated, oldVersion, atomicCt); - - await ApplyCredentialSessionPolicyAsync(context.ResourceTenant, userKey, CredentialChangeReason.Recovery, actorChainId: null, - now, atomicCt); + return await _atomicExecutor.ExecuteAsync( + async atomicCt => + { + // Re-read security state within the atomic scope. + // This protects against consuming a stale proof + // after another reset operation. + var currentState = await _authenticationSecurityManager + .GetOrCreateFactorAsync( + context.ResourceTenant, + userKey, + request.CredentialType, + atomicCt); + + if (!currentState.HasActiveReset(now) || + currentState.IsResetExpired(now) || + !_tokenHasher.Verify(currentState.ResetTokenHash!, request.ResetToken)) + { + if (context.IsAnonymousContext) + return CredentialActionResult.Success(); + + throw new UAuthConflictException("reset_request_not_active"); + } + + // Re-read the credential inside the atomic scope. + var currentCredential = await store.GetAsync( + new CredentialKey( + context.ResourceTenant, + pwd.Id), + atomicCt); + + if (currentCredential is not PasswordCredential currentPwd || + currentPwd.UserKey != userKey || + !currentPwd.Security.IsUsable(now)) + { + if (context.IsAnonymousContext) + return CredentialActionResult.Success(); + + throw new UAuthNotFoundException("credential_not_found"); + } + + // Strength + current password + history validation. + // Validation failure throws before token consumption. + var passwordContext = await _passwordContextProvider.CreateAsync(context.ResourceTenant, userKey, request.NewSecret, atomicCt); + await _passwordWriter.ChangeAsync( + currentPwd, + request.NewSecret, + passwordContext, + now, + atomicCt); + + var consumed = currentState.ConsumeReset(now); + + await _authenticationSecurityManager.UpdateAsync(consumed, currentState.SecurityVersion, atomicCt); + + await ApplyCredentialSessionPolicyAsync( + context.ResourceTenant, + userKey, + CredentialChangeReason.Recovery, + actorChainId: null, + now, + atomicCt); - return CredentialActionResult.Success(); - }, innerCt); + return CredentialActionResult.Success(); + }, + innerCt); }); return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); @@ -427,25 +464,38 @@ public async Task DeleteAsync(AccessContext context, Del { ct.ThrowIfCancellationRequested(); - var cmd = new AccessCommand(async innerCt => - { - var subjectUser = context.GetTargetUserKey(); - var now = _clock.UtcNow; + var cmd = new AccessCommand( + innerCt => _atomicExecutor.ExecuteAsync( + async atomicCt => + { + var subjectUser = context.GetTargetUserKey(); + var now = _clock.UtcNow; + var tenant = context.ResourceTenant; - var store = _credentialsFactory.Create(context.ResourceTenant); - var credential = await store.GetAsync(new CredentialKey(context.ResourceTenant, request.Id), innerCt); + var key = new CredentialKey(tenant, request.Id); - if (credential is not PasswordCredential pwd) - return CredentialActionResult.Fail("credential_not_found"); + var store = _credentialsFactory.Create(tenant); - if (pwd.UserKey != subjectUser) - return CredentialActionResult.Fail("credential_not_found"); + var credential = await store.GetAsync(key, atomicCt); - var oldVersion = pwd.Version; - await store.DeleteAsync(new CredentialKey(context.ResourceTenant, pwd.Id), oldVersion, request.Mode, now, innerCt); + if (credential is not PasswordCredential pwd) + return CredentialActionResult.Fail("credential_not_found"); - return CredentialActionResult.Success(); - }); + if (pwd.UserKey != subjectUser) + return CredentialActionResult.Fail("credential_not_found"); + + await store.DeleteAsync(key, pwd.Version, request.Mode, now, atomicCt); + + if (request.Mode == DeleteMode.Hard) + { + var historyStore = _historyStores.Create(tenant); + + await historyStore.DeleteByCredentialAsync(key, atomicCt); + } + + return CredentialActionResult.Success(); + }, + innerCt)); return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } @@ -457,8 +507,15 @@ async Task IUserCredentialsInternalService.DeleteInterna { ct.ThrowIfCancellationRequested(); - var store = _credentialsFactory.Create(tenant); - await store.DeleteByUserAsync(userKey, DeleteMode.Soft, _clock.UtcNow, ct); + await _atomicExecutor.ExecuteAsync( + async atomicCt => + { + var store = _credentialsFactory.Create(tenant); + + await store.DeleteByUserAsync(userKey, DeleteMode.Soft, _clock.UtcNow, atomicCt); + }, + ct); + return CredentialActionResult.Success(); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/PasswordCredentialWriter.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/PasswordCredentialWriter.cs new file mode 100644 index 00000000..129aa251 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/PasswordCredentialWriter.cs @@ -0,0 +1,102 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; +using CodeBeam.UltimateAuth.Credentials.Policies; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +internal sealed class PasswordCredentialWriter( + IPasswordValidationPipeline pipeline, + IPasswordCredentialStoreFactory credentialStores, + IPasswordHistoryStoreFactory historyStores, + IUAuthPasswordHasher hasher, + EffectivePasswordPolicyOptions options) +{ + public async Task CreateAsync(TenantKey tenant, UserKey userKey, string password, PasswordPolicyContext context, + DateTimeOffset now, CancellationToken ct) + { + var result = await pipeline.ValidateAsync( + new PasswordValidationRequest + { + Context = context with { Password = password } + }, + ct); + + EnsureValid(result); + + var credential = PasswordCredential.Create( + id: null, + tenant: tenant, + userKey: userKey, + secretHash: hasher.Hash(password), + security: CredentialSecurityState.Active(), + metadata: new CredentialMetadata(), + now: now); + + await credentialStores.Create(tenant).AddAsync(credential, ct); + + return credential; + } + + public async Task ChangeAsync(PasswordCredential credential, string newPassword, PasswordPolicyContext context, + DateTimeOffset now, CancellationToken ct) + { + var key = new CredentialKey(credential.Tenant, credential.Id); + + var result = await pipeline.ValidateAsync( + new PasswordValidationRequest + { + Context = context with + { + Password = newPassword + }, + ExistingCredentialKey = key + }, + ct); + + EnsureValid(result); + + var oldVersion = credential.Version; + var oldHash = credential.SecretHash; + var newHash = hasher.Hash(newPassword); + + var updated = credential.ChangeSecret(newHash, now); + + await credentialStores.Create(credential.Tenant).SaveAsync(updated, oldVersion, ct); + + var historyStore = historyStores.Create(credential.Tenant); + + if (options.RememberLast > 0) + { + await historyStore.AddAsync( + new PasswordHistoryEntry + { + Id = Guid.NewGuid(), + Tenant = credential.Tenant, + UserKey = credential.UserKey, + CredentialKey = key, + SecretHash = oldHash, + ReplacedAt = now + }, + ct); + } + + await historyStore.PruneAsync(key, options.RememberLast, ct); + + return updated; + } + + private static void EnsureValid(PasswordPolicyResult result) + { + if (result.Succeeded) + return; + + // Preserve the existing exception-based service contract. + // The endpoint/client error mapping can be expanded later + // to expose all violations. + throw new UAuthValidationException(result.Violations[0].Code); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Stores/IPasswordHistoryStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Stores/IPasswordHistoryStore.cs new file mode 100644 index 00000000..3780ed1e --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Stores/IPasswordHistoryStore.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public interface IPasswordHistoryStore +{ + Task> GetRecentAsync(CredentialKey credentialKey, int count, CancellationToken ct = default); + + Task AddAsync(PasswordHistoryEntry entry, CancellationToken ct = default); + + Task PruneAsync(CredentialKey credentialKey, int keepLast, CancellationToken ct = default); + + Task DeleteByCredentialAsync(CredentialKey credentialKey, CancellationToken ct = default); + Task DeleteByUserAsync(UserKey userKey, CancellationToken ct = default); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Stores/IPasswordHistoryStoreFactory.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Stores/IPasswordHistoryStoreFactory.cs new file mode 100644 index 00000000..705d4ea3 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Stores/IPasswordHistoryStoreFactory.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public interface IPasswordHistoryStoreFactory +{ + IPasswordHistoryStore Create(TenantKey tenant); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Extensions/PasswordPolicyServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Extensions/PasswordPolicyServiceCollectionExtensions.cs new file mode 100644 index 00000000..e45de57a --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Extensions/PasswordPolicyServiceCollectionExtensions.cs @@ -0,0 +1,48 @@ +using CodeBeam.UltimateAuth.Credentials.Options; +using CodeBeam.UltimateAuth.Credentials.Policies; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Credentials.Extensions; + +public static class PasswordPolicyServiceCollectionExtensions +{ + public static IServiceCollection AddPasswordStrengthPolicy(this IServiceCollection services) + { + services.TryAddScoped(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddScoped(); + + return services; + } + + public static IServiceCollection AddPasswordPolicyOptions(this IServiceCollection services, Action? configure = null) + { + var builder = services.AddOptions(); + + if (configure is not null) + builder.Configure(configure); + + builder.ValidateOnStart(); + + services.TryAddSingleton, PasswordPolicyOptionsValidator>(); + + services.TryAddSingleton(sp => + { + var options = sp.GetRequiredService>().Value; + + return PasswordPolicyOptionsResolver.Resolve(options); + }); + + return services; + } + + public static IServiceCollection ReplacePasswordStrengthPolicy(this IServiceCollection services) where TPolicy : class, IPasswordStrengthPolicy + { + services.Replace(ServiceDescriptor.Scoped()); + + return services; + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/EffectivePasswordPolicyOptions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/EffectivePasswordPolicyOptions.cs new file mode 100644 index 00000000..6ec7b0e7 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/EffectivePasswordPolicyOptions.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Options; + +public sealed record EffectivePasswordPolicyOptions +{ + public required PasswordPolicyLevel Level { get; init; } + + public required int MinLength { get; init; } + public required int MaxLength { get; init; } + + public required bool RequireLetter { get; init; } + public required bool RequireDigit { get; init; } + public required bool RequireUppercase { get; init; } + public required bool RequireLowercase { get; init; } + public required bool RequireSpecialCharacter { get; init; } + + public required PasswordPolicyLevel CommonPasswords { get; init; } + public required PasswordPolicyLevel UserSimilarity { get; init; } + + public required int RememberLast { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordHistoryOptions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordHistoryOptions.cs new file mode 100644 index 00000000..36620e5c --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordHistoryOptions.cs @@ -0,0 +1,15 @@ +namespace CodeBeam.UltimateAuth.Credentials.Options; + +public sealed class PasswordHistoryOptions +{ + /// + /// Number of previous passwords that cannot be reused. + /// Zero disables password history enforcement. + /// + public int RememberLast { get; set; } = 0; + + public PasswordHistoryOptions Clone() => new() + { + RememberLast = RememberLast + }; +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyLimits.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyLimits.cs new file mode 100644 index 00000000..ef275566 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyLimits.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Credentials.Options; + +public static class PasswordPolicyLimits +{ + public const int MinimumLength = 6; + public const int AbsoluteMaxLength = 256; + + public const int DefaultMaxLength = 128; + + public const int MaxHistoryCount = 10; +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyOptions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyOptions.cs new file mode 100644 index 00000000..9b157da2 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyOptions.cs @@ -0,0 +1,37 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Options; + +public sealed class PasswordPolicyOptions +{ + public PasswordPolicyLevel Level { get; set; } = PasswordPolicyLevel.Standard; + + public int? MinLength { get; set; } + public int? MaxLength { get; set; } + + public bool? RequireLetter { get; set; } + public bool? RequireDigit { get; set; } + public bool? RequireUppercase { get; set; } + public bool? RequireLowercase { get; set; } + public bool? RequireSpecialCharacter { get; set; } + + public PasswordPolicyLevel? CommonPasswords { get; set; } + public PasswordPolicyLevel? UserSimilarity { get; set; } + + public PasswordHistoryOptions History { get; set; } = new(); + + public PasswordPolicyOptions Clone() => new() + { + Level = Level, + MinLength = MinLength, + MaxLength = MaxLength, + RequireLetter = RequireLetter, + RequireDigit = RequireDigit, + RequireUppercase = RequireUppercase, + RequireLowercase = RequireLowercase, + RequireSpecialCharacter = RequireSpecialCharacter, + CommonPasswords = CommonPasswords, + UserSimilarity = UserSimilarity, + History = History.Clone() + }; +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyOptionsResolver.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyOptionsResolver.cs new file mode 100644 index 00000000..b1823e31 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyOptionsResolver.cs @@ -0,0 +1,93 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Options; + +public static class PasswordPolicyOptionsResolver +{ + public static EffectivePasswordPolicyOptions Resolve(PasswordPolicyOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var preset = options.Level switch + { + PasswordPolicyLevel.Basic => CreateBasic(), + PasswordPolicyLevel.Standard => CreateStandard(), + PasswordPolicyLevel.Strict => CreateStrict(), + + _ => throw new ArgumentOutOfRangeException(nameof(options), "Invalid password policy level.") + }; + + return preset with + { + MinLength = options.MinLength ?? preset.MinLength, + MaxLength = options.MaxLength ?? preset.MaxLength, + + RequireLetter = options.RequireLetter ?? preset.RequireLetter, + RequireDigit = options.RequireDigit ?? preset.RequireDigit, + RequireUppercase = options.RequireUppercase ?? preset.RequireUppercase, + RequireLowercase = options.RequireLowercase ?? preset.RequireLowercase, + RequireSpecialCharacter = options.RequireSpecialCharacter ?? preset.RequireSpecialCharacter, + CommonPasswords = options.CommonPasswords ?? preset.CommonPasswords, + UserSimilarity = options.UserSimilarity ?? preset.UserSimilarity, + RememberLast = options.History?.RememberLast ?? 0 + }; + } + + private static EffectivePasswordPolicyOptions CreateBasic() => + Create( + PasswordPolicyLevel.Basic, + minLength: 8, + requireLetter: false, + requireDigit: false, + requireUppercase: false, + requireLowercase: false, + requireSpecialCharacter: false); + + private static EffectivePasswordPolicyOptions CreateStandard() => + Create( + PasswordPolicyLevel.Standard, + minLength: 8, + requireLetter: true, + requireDigit: true, + requireUppercase: false, + requireLowercase: false, + requireSpecialCharacter: false); + + private static EffectivePasswordPolicyOptions CreateStrict() => + Create( + PasswordPolicyLevel.Strict, + minLength: 15, + requireLetter: true, + requireDigit: true, + requireUppercase: true, + requireLowercase: true, + requireSpecialCharacter: true); + + private static EffectivePasswordPolicyOptions Create( + PasswordPolicyLevel level, + int minLength, + bool requireLetter, + bool requireDigit, + bool requireUppercase, + bool requireLowercase, + bool requireSpecialCharacter) + { + return new EffectivePasswordPolicyOptions + { + Level = level, + MinLength = minLength, + MaxLength = PasswordPolicyLimits.DefaultMaxLength, + + RequireLetter = requireLetter, + RequireDigit = requireDigit, + RequireUppercase = requireUppercase, + RequireLowercase = requireLowercase, + RequireSpecialCharacter = requireSpecialCharacter, + + CommonPasswords = level, + UserSimilarity = level, + + RememberLast = 0 + }; + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyOptionsValidator.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyOptionsValidator.cs new file mode 100644 index 00000000..284da376 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyOptionsValidator.cs @@ -0,0 +1,82 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Credentials.Options; + +public sealed class PasswordPolicyOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, PasswordPolicyOptions options) + { + var errors = new List(); + + if (options is null) + { + return ValidateOptionsResult.Fail("Password policy options cannot be null."); + } + + if (options.Level is not ( + PasswordPolicyLevel.Basic or + PasswordPolicyLevel.Standard or + PasswordPolicyLevel.Strict)) + { + errors.Add("Password policy Level must be Basic, Standard or Strict."); + } + + if (!IsValidCheckLevel(options.CommonPasswords)) + { + errors.Add("Invalid CommonPasswords level."); + } + + if (!IsValidCheckLevel(options.UserSimilarity)) + { + errors.Add("Invalid UserSimilarity level."); + } + + if (options.History is null) + { + errors.Add("Password History options cannot be null."); + } + else if (options.History.RememberLast < 0 || options.History.RememberLast > PasswordPolicyLimits.MaxHistoryCount) + { + errors.Add("Password history count must be between 0 and 10."); + } + + if (errors.Count > 0) + return ValidateOptionsResult.Fail(errors); + + var effective = PasswordPolicyOptionsResolver.Resolve(options); + + if (effective.MinLength < PasswordPolicyLimits.MinimumLength) + { + errors.Add($"Password MinLength must be at least " + $"{PasswordPolicyLimits.MinimumLength}."); + } + + if (effective.MaxLength > PasswordPolicyLimits.AbsoluteMaxLength) + { + errors.Add($"Password MaxLength cannot exceed " + $"{PasswordPolicyLimits.AbsoluteMaxLength}."); + } + + if (effective.MaxLength < effective.MinLength) + { + errors.Add("Password MaxLength cannot be smaller than MinLength."); + } + + if (effective.MaxLength <= 0) + { + errors.Add("Password MaxLength must be positive."); + } + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } + + private static bool IsValidCheckLevel(PasswordPolicyLevel? level) + { + return level is null or + PasswordPolicyLevel.Disabled or + PasswordPolicyLevel.Basic or + PasswordPolicyLevel.Standard or + PasswordPolicyLevel.Strict; + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/BuiltInCommonPasswordChecker.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/BuiltInCommonPasswordChecker.cs new file mode 100644 index 00000000..fe37dde2 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/BuiltInCommonPasswordChecker.cs @@ -0,0 +1,130 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Policies; + +/// +/// Lightweight, offline common-password checker. +/// Provides baseline protection without an external dataset. +/// +public sealed class BuiltInCommonPasswordChecker : IPasswordCommonPasswordChecker +{ + private static readonly HashSet BasicPasswords = + new(StringComparer.OrdinalIgnoreCase) + { + "password", + "123456", + "12345678", + "123456789", + "1234567890", + "12345", + "1234567", + "12345678910", + "111111", + "000000", + "qwerty", + "qwerty123", + "abc123", + "admin", + "admin123", + "letmein", + "welcome", + "iloveyou", + "password1", + "password123" + }; + + private static readonly HashSet StandardPasswords = + new(StringComparer.OrdinalIgnoreCase) + { + "123123", + "654321", + "112233", + "121212", + "123321", + "qwertyuiop", + "asdfgh", + "asdfghjkl", + "zxcvbn", + "1q2w3e4r", + "1q2w3e4r5t", + "1qaz2wsx", + "zaq12wsx", + "passw0rd", + "p@ssw0rd", + "p@ssword", + "password!", + "password@123", + "welcome123", + "administrator", + "root123", + "test123", + "changeme", + "default", + "secret", + "secret123", + "football", + "baseball", + "monkey", + "dragon", + "sunshine", + "princess", + "superman", + "master", + "trustno1" + }; + + private static readonly HashSet StrictPasswords = + new(StringComparer.OrdinalIgnoreCase) + { + "password123!", + "password@123!", + "admin123!", + "welcome123!", + "qwerty123!", + "letmein123", + "administrator123", + "passw0rd123", + "p@ssw0rd123", + "iloveyou123", + "123456789abc", + "abc123456789", + "qwerty123456", + "asdfgh123456", + "changeme123", + "defaultpassword", + "temporarypassword", + "temporary123", + "mysecretpassword", + "supersecret", + "password2024", + "password2025", + "password2026" + }; + + public Task IsCommonAsync(string password, PasswordPolicyLevel level, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(password); + + var result = level switch + { + PasswordPolicyLevel.Disabled => false, + + PasswordPolicyLevel.Basic => + BasicPasswords.Contains(password), + + PasswordPolicyLevel.Standard => + BasicPasswords.Contains(password) || + StandardPasswords.Contains(password), + + PasswordPolicyLevel.Strict => + BasicPasswords.Contains(password) || + StandardPasswords.Contains(password) || + StrictPasswords.Contains(password), + + _ => throw new ArgumentOutOfRangeException(nameof(level)) + }; + + return Task.FromResult(result); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/DefaultPasswordPolicyContextProvider.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/DefaultPasswordPolicyContextProvider.cs new file mode 100644 index 00000000..e41a2fe9 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/DefaultPasswordPolicyContextProvider.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Credentials.Policies; + +public sealed class DefaultPasswordPolicyContextProvider : IPasswordPolicyContextProvider +{ + public Task CreateAsync(TenantKey tenant, UserKey userKey, string password, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return Task.FromResult(new PasswordPolicyContext + { + Password = password + }); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/DefaultPasswordStrengthPolicy.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/DefaultPasswordStrengthPolicy.cs new file mode 100644 index 00000000..0c5d797c --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/DefaultPasswordStrengthPolicy.cs @@ -0,0 +1,106 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; + +namespace CodeBeam.UltimateAuth.Credentials.Policies; + +public sealed class DefaultPasswordStrengthPolicy(IPasswordCommonPasswordChecker commonPasswordChecker, IPasswordUserSimilarityChecker userSimilarityChecker) : IPasswordStrengthPolicy +{ + public async Task ValidateAsync(PasswordPolicyContext context, EffectivePasswordPolicyOptions options, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(options); + + ct.ThrowIfCancellationRequested(); + + var violations = new List(); + var password = context.Password; + + if (string.IsNullOrEmpty(password)) + { + violations.Add(new(PasswordPolicyErrorCodes.Required, "Password is required.")); + + return new PasswordPolicyResult(violations); + } + + // Defensive check. The future pipeline will enforce + // the absolute input limit independently of this policy. + if (password.Length > PasswordPolicyLimits.AbsoluteMaxLength) + { + violations.Add(new(PasswordPolicyErrorCodes.InputTooLong, "Password exceeds the absolute length limit.")); + + return new PasswordPolicyResult(violations); + } + + if (password.Length < options.MinLength) + { + violations.Add(new(PasswordPolicyErrorCodes.TooShort, $"Password must contain at least {options.MinLength} characters.")); + } + + if (password.Length > options.MaxLength) + { + violations.Add(new(PasswordPolicyErrorCodes.TooLong, $"Password cannot exceed {options.MaxLength} characters.")); + } + + bool hasLetter = false; + bool hasDigit = false; + bool hasUppercase = false; + bool hasLowercase = false; + bool hasSpecialCharacter = false; + + foreach (var c in password) + { + if (char.IsLetter(c)) + hasLetter = true; + + if (char.IsDigit(c)) + hasDigit = true; + + if (char.IsUpper(c)) + hasUppercase = true; + + if (char.IsLower(c)) + hasLowercase = true; + + if (!char.IsLetterOrDigit(c)) + hasSpecialCharacter = true; + } + + if (options.RequireLetter && !hasLetter) + { + violations.Add(new(PasswordPolicyErrorCodes.LetterRequired, "Password must contain a letter.")); + } + + if (options.RequireDigit && !hasDigit) + { + violations.Add(new(PasswordPolicyErrorCodes.DigitRequired, "Password must contain a digit.")); + } + + if (options.RequireUppercase && !hasUppercase) + { + violations.Add(new(PasswordPolicyErrorCodes.UppercaseRequired, "Password must contain an uppercase letter.")); + } + + if (options.RequireLowercase && !hasLowercase) + { + violations.Add(new(PasswordPolicyErrorCodes.LowercaseRequired, "Password must contain a lowercase letter.")); + } + + if (options.RequireSpecialCharacter && !hasSpecialCharacter) + { + violations.Add(new(PasswordPolicyErrorCodes.SpecialCharacterRequired, "Password must contain a special character.")); + } + + if (options.CommonPasswords != PasswordPolicyLevel.Disabled && + await commonPasswordChecker.IsCommonAsync(password, options.CommonPasswords, ct)) + { + violations.Add(new(PasswordPolicyErrorCodes.CommonPassword, "Password is too common.")); + } + + if (options.UserSimilarity != PasswordPolicyLevel.Disabled && userSimilarityChecker.IsSimilar(context, options.UserSimilarity)) + { + violations.Add(new(PasswordPolicyErrorCodes.SimilarToUser, "Password is too similar to user information.")); + } + + return new PasswordPolicyResult(violations); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/DefaultPasswordUserSimilarityChecker.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/DefaultPasswordUserSimilarityChecker.cs new file mode 100644 index 00000000..0b3a9012 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/DefaultPasswordUserSimilarityChecker.cs @@ -0,0 +1,113 @@ +using System.Text; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Policies; + +public sealed class DefaultPasswordUserSimilarityChecker : IPasswordUserSimilarityChecker +{ + public bool IsSimilar(PasswordPolicyContext context, PasswordPolicyLevel level) + { + ArgumentNullException.ThrowIfNull(context); + + if (level == PasswordPolicyLevel.Disabled) + return false; + + if (level is not ( + PasswordPolicyLevel.Basic or + PasswordPolicyLevel.Standard or + PasswordPolicyLevel.Strict)) + { + throw new ArgumentOutOfRangeException(nameof(level)); + } + + var password = context.Password; + + if (string.IsNullOrEmpty(password)) + return false; + + var candidates = GetCandidates(context); + + foreach (var candidate in candidates) + { + if (string.Equals(password, candidate, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (level >= PasswordPolicyLevel.Standard && candidate.Length >= 4 && password.Contains(candidate, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (level == PasswordPolicyLevel.Strict && candidate.Length >= 4 && IsStrictMatch(password, candidate)) + { + return true; + } + } + + return false; + } + + private static IEnumerable GetCandidates(PasswordPolicyContext context) + { + var values = new List(); + + if (!string.IsNullOrWhiteSpace(context.UserName)) + values.Add(context.UserName); + + values.AddRange(context.UserIdentifiers ?? Array.Empty()); + + var candidates = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var value in values) + { + var trimmed = value.Trim(); + + if (trimmed.Length == 0) + continue; + + candidates.Add(trimmed); + + var atIndex = trimmed.IndexOf('@'); + + if (atIndex > 0) + candidates.Add(trimmed[..atIndex]); + } + + return candidates; + } + + private static bool IsStrictMatch(string password, string candidate) + { + var normalizedPassword = Normalize(password); + var normalizedCandidate = Normalize(candidate); + + return normalizedCandidate.Length >= 4 && normalizedPassword.Contains(normalizedCandidate, StringComparison.Ordinal); + } + + private static string Normalize(string value) + { + var builder = new StringBuilder(value.Length); + + foreach (var c in value.ToLowerInvariant()) + { + var normalized = c switch + { + '0' => 'o', + '1' => 'i', + '3' => 'e', + '4' => 'a', + '5' => 's', + '7' => 't', + '@' => 'a', + '$' => 's', + _ => c + }; + + if (char.IsLetterOrDigit(normalized)) + builder.Append(normalized); + } + + return builder.ToString(); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordCommonPasswordChecker.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordCommonPasswordChecker.cs new file mode 100644 index 00000000..5deb5d95 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordCommonPasswordChecker.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Policies; + +public interface IPasswordCommonPasswordChecker +{ + Task IsCommonAsync(string password, PasswordPolicyLevel level, CancellationToken ct = default); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordPolicyContextProvider.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordPolicyContextProvider.cs new file mode 100644 index 00000000..919ecfaa --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordPolicyContextProvider.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Credentials.Policies; + +public interface IPasswordPolicyContextProvider +{ + Task CreateAsync(TenantKey tenant, UserKey userKey, string password, CancellationToken ct = default); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordStrengthPolicy.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordStrengthPolicy.cs new file mode 100644 index 00000000..ca05c92b --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordStrengthPolicy.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; + +namespace CodeBeam.UltimateAuth.Credentials.Policies; + +public interface IPasswordStrengthPolicy +{ + Task ValidateAsync(PasswordPolicyContext context, EffectivePasswordPolicyOptions options, CancellationToken ct = default); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordUserSimilarityChecker.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordUserSimilarityChecker.cs new file mode 100644 index 00000000..81cf1a36 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordUserSimilarityChecker.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Policies; + +public interface IPasswordUserSimilarityChecker +{ + bool IsSimilar(PasswordPolicyContext context, PasswordPolicyLevel level); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/PasswordPolicyContext.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/PasswordPolicyContext.cs new file mode 100644 index 00000000..ed84767a --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/PasswordPolicyContext.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Credentials.Policies; + +public sealed record PasswordPolicyContext +{ + public required string Password { get; init; } + + public string? UserName { get; init; } + + public IReadOnlyList UserIdentifiers { get; init; } = Array.Empty(); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs index 35c99919..1ba6d18c 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs @@ -1,8 +1,9 @@ -using CodeBeam.UltimateAuth.Server.Endpoints; -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Credentials.Policies; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using CodeBeam.UltimateAuth.Server.Services; namespace CodeBeam.UltimateAuth.Users.Reference.Extensions; public static class ServiceCollectionExtensions @@ -22,6 +23,7 @@ public static IServiceCollection AddUltimateAuthUsersReference(this IServiceColl services.TryAddScoped(); services.TryAddScoped(); services.AddScoped(); + services.AddScoped(); services.TryAddScoped(); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserPasswordPolicyContextProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserPasswordPolicyContextProvider.cs new file mode 100644 index 00000000..886bba48 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserPasswordPolicyContextProvider.cs @@ -0,0 +1,39 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Policies; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class UserPasswordPolicyContextProvider(IUserIdentifierStoreFactory storeFactory) : IPasswordPolicyContextProvider +{ + public async Task CreateAsync(TenantKey tenant, UserKey userKey, string password, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var store = storeFactory.Create(tenant); + + var identifiers = await store.GetByUserAsync(userKey, ct); + + var active = identifiers.Where(x => !x.IsDeleted).ToArray(); + + var userName = active + .Where(x => x.Type == UserIdentifierType.Username) + .OrderByDescending(x => x.IsPrimary) + .Select(x => x.Value) + .FirstOrDefault(); + + var values = active + .Select(x => x.Value) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return new PasswordPolicyContext + { + Password = password, + UserName = userName, + UserIdentifiers = values + }; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs index 23c7c56b..8d13b662 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core; +using AwesomeAssertions; +using CodeBeam.UltimateAuth.Core; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Defaults; @@ -8,12 +9,13 @@ using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; +using CodeBeam.UltimateAuth.Credentials.Policies; using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using CodeBeam.UltimateAuth.Users; using CodeBeam.UltimateAuth.Users.Contracts; -using AwesomeAssertions; using Microsoft.Extensions.Options; using Moq; @@ -864,10 +866,185 @@ public async Task CompleteResetAsync_Anonymous_UnknownIdentifier_DoesNotRequireT Times.Never); } - private static LoginIdentifierResolution CreateResolution( - AccessContext context, - string identifier, - UserKey userKey) + [Fact] + public async Task AddAsync_WhenPasswordPolicyRejects_DoesNotHashOrPersist() + { + var f = CreateFixture(); + var user = UserKey.New(); + + var context = TestAccessContext.ForUser( + user, + UAuthActions.Credentials.AddSelf); + + f.PasswordPipeline + .Setup(x => x.ValidateAsync( + It.Is(r => + r.Context.Password == "weak"), + It.IsAny())) + .ReturnsAsync(PasswordPolicyResult.Failure( + new PasswordPolicyViolation( + PasswordPolicyErrorCodes.TooShort, + "Password is too short."))); + + var act = () => f.Sut.AddAsync( + context, + new AddCredentialRequest + { + Secret = "weak" + }); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should() + .Be(PasswordPolicyErrorCodes.TooShort); + + f.Hasher.Verify( + x => x.Hash(It.IsAny()), + Times.Never); + + f.CredentialStore.Verify( + x => x.AddAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ChangeSecretAsync_WhenPolicyRejects_DoesNotSaveOrRevokeSessions() + { + var f = CreateFixture(); + var user = UserKey.New(); + + var context = TestAccessContext.ForUser( + user, + UAuthActions.Credentials.ChangeSelf); + + var credential = CreateCredential( + user, + f.OldPasswordHash); + + f.CredentialStore + .Setup(x => x.GetByUserAsync( + user, + It.IsAny())) + .ReturnsAsync(new[] { credential }); + + f.Hasher + .Setup(x => x.Verify( + f.OldPasswordHash, + "current-password")) + .Returns(true); + + f.PasswordPipeline + .Setup(x => x.ValidateAsync( + It.Is(r => + r.Context.Password == "weak"), + It.IsAny())) + .ReturnsAsync(PasswordPolicyResult.Failure( + new PasswordPolicyViolation( + PasswordPolicyErrorCodes.TooShort, + "Password is too short."))); + + var act = () => f.Sut.ChangeSecretAsync( + context, + new ChangeCredentialRequest + { + CurrentSecret = "current-password", + NewSecret = "weak" + }); + + await act.Should() + .ThrowAsync(); + + f.CredentialStore.Verify( + x => x.SaveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + + f.SessionStore.Verify( + x => x.RevokeAllChainsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + + f.SessionStore.Verify( + x => x.RevokeOtherChainsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task DeleteAsync_Hard_RemovesPasswordHistory() + { + var f = CreateFixture(); + var user = UserKey.New(); + var id = Guid.NewGuid(); + + var context = TestAccessContext.ForUser( + user, + UAuthActions.Credentials.DeleteAdmin); + + var credential = CreateCredential( + user, + f.OldPasswordHash, + id: id, + version: 5); + + var key = new CredentialKey( + context.ResourceTenant, + id); + + f.CredentialStore + .Setup(x => x.GetAsync( + key, + It.IsAny())) + .ReturnsAsync(credential); + + f.CredentialStore + .Setup(x => x.DeleteAsync( + key, + 5, + DeleteMode.Hard, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + f.HistoryStore + .Setup(x => x.DeleteByCredentialAsync( + key, + It.IsAny())) + .Returns(Task.CompletedTask); + + var result = await f.Sut.DeleteAsync( + context, + new DeleteCredentialRequest + { + Id = id, + Mode = DeleteMode.Hard + }); + + result.IsSuccess.Should().BeTrue(); + + f.HistoryStore.Verify( + x => x.DeleteByCredentialAsync( + key, + It.IsAny()), + Times.Once); + } + + + // --------------------------------------------------------- + // Helpers + // --------------------------------------------------------- + + private static LoginIdentifierResolution CreateResolution(AccessContext context, string identifier, UserKey userKey) { return new LoginIdentifierResolution { @@ -880,16 +1057,7 @@ private static LoginIdentifierResolution CreateResolution( }; } - - // --------------------------------------------------------- - // Helpers - // --------------------------------------------------------- - - private static PasswordCredential CreateCredential( - UserKey user, - PasswordHash hash, - Guid? id = null, - long version = 0) + private static PasswordCredential CreateCredential(UserKey user, PasswordHash hash, Guid? id = null, long version = 0) { return PasswordCredential.FromProjection( id: id ?? Guid.NewGuid(), @@ -991,6 +1159,18 @@ private sealed class Fixture public Mock AtomicExecutor { get; } = new(MockBehavior.Strict); + public Mock HistoryStoreFactory { get; } + = new(MockBehavior.Strict); + + public Mock HistoryStore { get; } + = new(MockBehavior.Strict); + + public Mock PasswordContextProvider { get; } + = new(MockBehavior.Strict); + + public Mock PasswordPipeline { get; } + = new(MockBehavior.Strict); + public Mock Clock { get; } = new(MockBehavior.Strict); @@ -1100,9 +1280,48 @@ public Fixture() .Returns>, CancellationToken>( (action, ct) => action(ct)); + AtomicExecutor + .Setup(x => x.ExecuteAsync( + It.IsAny>>(), + It.IsAny())) + .Returns>, + CancellationToken>( + (action, ct) => action(ct)); + + HistoryStoreFactory + .Setup(x => x.Create(It.IsAny())) + .Returns(HistoryStore.Object); + + PasswordContextProvider + .Setup(x => x.CreateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns( + (tenant, user, password, ct) => + Task.FromResult(new PasswordPolicyContext + { + Password = password + })); + PasswordPipeline + .Setup(x => x.ValidateAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(PasswordPolicyResult.Success()); + var options = Options.Create( TestServerOptions.Default()); + var effectiveOptions = PasswordPolicyOptionsResolver.Resolve(new PasswordPolicyOptions()); + + var passwordWriter = new PasswordCredentialWriter( + PasswordPipeline.Object, + CredentialStoreFactory.Object, + HistoryStoreFactory.Object, + Hasher.Object, + effectiveOptions); + Sut = new CredentialManagementService( AccessOrchestrator.Object, CredentialStoreFactory.Object, @@ -1116,6 +1335,9 @@ public Fixture() ResetNotifier.Object, CredentialSessionPolicy.Object, AtomicExecutor.Object, + passwordWriter, + HistoryStoreFactory.Object, + PasswordContextProvider.Object, options, Clock.Object); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/DefaultPasswordStrengthPolicyTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/DefaultPasswordStrengthPolicyTests.cs new file mode 100644 index 00000000..131548d9 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/DefaultPasswordStrengthPolicyTests.cs @@ -0,0 +1,124 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; +using CodeBeam.UltimateAuth.Credentials.Policies; + +public sealed class DefaultPasswordStrengthPolicyTests +{ + private readonly IPasswordStrengthPolicy _policy = new DefaultPasswordStrengthPolicy(new BuiltInCommonPasswordChecker(), new DefaultPasswordUserSimilarityChecker()); + + private static EffectivePasswordPolicyOptions Resolve(PasswordPolicyLevel level = PasswordPolicyLevel.Standard) + => PasswordPolicyOptionsResolver.Resolve(new PasswordPolicyOptions { Level = level }); + + private Task ValidateAsync(string password, PasswordPolicyLevel level = PasswordPolicyLevel.Standard, string? userName = null) + { + return _policy.ValidateAsync( + new PasswordPolicyContext + { + Password = password, + UserName = userName + }, + Resolve(level)); + } + + [Fact] + public async Task Standard_RequiresLetterAndDigit() + { + var result = await ValidateAsync("abcdefgh"); + + Assert.False(result.Succeeded); + Assert.Contains( + result.Violations, + x => x.Code == PasswordPolicyErrorCodes.DigitRequired); + } + + [Fact] + public async Task Basic_AllowsPasswordWithoutDigits() + { + var result = await ValidateAsync( + "longpassphrase", + PasswordPolicyLevel.Basic); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task Standard_RejectsCommonPassword() + { + var result = await ValidateAsync("password123"); + + Assert.Contains( + result.Violations, + x => x.Code == PasswordPolicyErrorCodes.CommonPassword); + } + + [Fact] + public async Task Standard_RejectsUsernameInPassword() + { + var result = await ValidateAsync( + "JohnSmith123!", + userName: "johnsmith"); + + Assert.Contains( + result.Violations, + x => x.Code == PasswordPolicyErrorCodes.SimilarToUser); + } + + [Fact] + public async Task Strict_RejectsNormalizedUsername() + { + var result = await ValidateAsync( + "J0hn-Smith123!", + PasswordPolicyLevel.Strict, + "johnsmith"); + + Assert.Contains( + result.Violations, + x => x.Code == PasswordPolicyErrorCodes.SimilarToUser); + } + + [Fact] + public async Task Strict_RequiresAllConfiguredClasses() + { + var result = await ValidateAsync( + "longpassword123", + PasswordPolicyLevel.Strict); + + Assert.Contains( + result.Violations, + x => x.Code == PasswordPolicyErrorCodes.UppercaseRequired); + + Assert.Contains( + result.Violations, + x => x.Code == PasswordPolicyErrorCodes.SpecialCharacterRequired); + } + + [Fact] + public async Task EmptyPassword_ReturnsRequiredViolation() + { + var result = await ValidateAsync(""); + + Assert.Contains( + result.Violations, + x => x.Code == PasswordPolicyErrorCodes.Required); + } + + [Fact] + public async Task ExcessiveInput_IsRejectedBeforeOtherChecks() + { + var result = await ValidateAsync(new string('a', 257)); + + Assert.Single(result.Violations); + Assert.Equal( + PasswordPolicyErrorCodes.InputTooLong, + result.Violations[0].Code); + } + + [Fact] + public async Task ValidStandardPassword_Succeeds() + { + var result = await ValidateAsync("BlueRiver4827!"); + + Assert.True(result.Succeeded); + Assert.Empty(result.Violations); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordPolicyOptionsTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordPolicyOptionsTests.cs new file mode 100644 index 00000000..87a8bec1 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordPolicyOptionsTests.cs @@ -0,0 +1,116 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials; + +public sealed class PasswordPolicyOptionsTests +{ + private readonly PasswordPolicyOptionsValidator _validator = new(); + + [Fact] + public void DefaultOptions_ResolveToStandard() + { + var effective = PasswordPolicyOptionsResolver.Resolve(new()); + + Assert.Equal(PasswordPolicyLevel.Standard, effective.Level); + Assert.Equal(8, effective.MinLength); + Assert.Equal(128, effective.MaxLength); + Assert.True(effective.RequireLetter); + Assert.True(effective.RequireDigit); + Assert.False(effective.RequireSpecialCharacter); + Assert.Equal(0, effective.RememberLast); + } + + [Fact] + public void BasicPreset_DoesNotRequireCharacterClasses() + { + var effective = PasswordPolicyOptionsResolver.Resolve( + new PasswordPolicyOptions + { + Level = PasswordPolicyLevel.Basic + }); + + Assert.False(effective.RequireLetter); + Assert.False(effective.RequireDigit); + Assert.False(effective.RequireUppercase); + Assert.False(effective.RequireSpecialCharacter); + } + + [Fact] + public void Overrides_TakePrecedenceOverPreset() + { + var effective = PasswordPolicyOptionsResolver.Resolve( + new PasswordPolicyOptions + { + Level = PasswordPolicyLevel.Strict, + MinLength = 12, + RequireDigit = false, + CommonPasswords = PasswordPolicyLevel.Basic + }); + + Assert.Equal(12, effective.MinLength); + Assert.False(effective.RequireDigit); + Assert.True(effective.RequireUppercase); + Assert.Equal(PasswordPolicyLevel.Basic, effective.CommonPasswords); + } + + [Theory] + [InlineData(PasswordPolicyLevel.Disabled)] + [InlineData((PasswordPolicyLevel)999)] + public void InvalidGlobalLevel_IsRejected(PasswordPolicyLevel level) + { + var result = _validator.Validate( + null, + new PasswordPolicyOptions { Level = level }); + + Assert.True(result.Failed); + } + + [Theory] + [InlineData(5, 128)] + [InlineData(8, 257)] + [InlineData(150, 128)] + public void InvalidLengths_AreRejected(int min, int max) + { + var result = _validator.Validate( + null, + new PasswordPolicyOptions + { + MinLength = min, + MaxLength = max + }); + + Assert.True(result.Failed); + } + + [Theory] + [InlineData(-1)] + [InlineData(11)] + public void InvalidHistoryCount_IsRejected(int count) + { + var result = _validator.Validate( + null, + new PasswordPolicyOptions + { + History = new PasswordHistoryOptions + { + RememberLast = count + } + }); + + Assert.True(result.Failed); + } + + [Fact] + public void DisabledIndividualCheck_IsAllowed() + { + var options = new PasswordPolicyOptions + { + UserSimilarity = PasswordPolicyLevel.Disabled + }; + + var result = _validator.Validate(null, options); + + Assert.True(result.Succeeded); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordValidationPipelineTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordValidationPipelineTests.cs new file mode 100644 index 00000000..8270dd2f --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordValidationPipelineTests.cs @@ -0,0 +1,74 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; +using CodeBeam.UltimateAuth.Credentials.Policies; +using CodeBeam.UltimateAuth.Credentials.Reference; + +public sealed class PasswordValidationPipelineTests +{ + private sealed class AcceptAllStrengthPolicy : IPasswordStrengthPolicy + { + public Task ValidateAsync(PasswordPolicyContext context, EffectivePasswordPolicyOptions options, CancellationToken ct = default) + { + return Task.FromResult(PasswordPolicyResult.Success()); + } + } + + private sealed class RecordingHistoryValidator : IPasswordHistoryValidator + { + public int CallCount { get; private set; } + + public Task ValidateAsync(CredentialKey credentialKey, string password, int rememberLast, CancellationToken ct = default) + { + CallCount++; + + return Task.FromResult(PasswordPolicyResult.Failure(new PasswordPolicyViolation(PasswordPolicyErrorCodes.ReusedPassword, "Password was used previously."))); + } + } + + private static DefaultPasswordValidationPipeline CreatePipeline(RecordingHistoryValidator history) + { + return new DefaultPasswordValidationPipeline(new AcceptAllStrengthPolicy(), history, PasswordPolicyOptionsResolver.Resolve(new())); + } + + [Fact] + public async Task AbsoluteLimit_CannotBeBypassedByCustomStrengthPolicy() + { + var history = new RecordingHistoryValidator(); + var pipeline = CreatePipeline(history); + + var result = await pipeline.ValidateAsync( + new PasswordValidationRequest + { + Context = new PasswordPolicyContext + { + Password = new string('a', 257) + } + }); + + Assert.False(result.Succeeded); + Assert.Contains( + result.Violations, + x => x.Code == PasswordPolicyErrorCodes.InputTooLong); + + Assert.Equal(0, history.CallCount); + } + + [Fact] + public async Task NewCredential_DoesNotRequireHistory() + { + var history = new RecordingHistoryValidator(); + var pipeline = CreatePipeline(history); + + var result = await pipeline.ValidateAsync( + new PasswordValidationRequest + { + Context = new PasswordPolicyContext + { + Password = "NewPassword123!" + } + }); + + Assert.True(result.Succeeded); + Assert.Equal(0, history.CallCount); + } +} From b2645ec2e8df21a46cf8db2fc5dad574df3f2d56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 2 Nov 2026 00:28:31 +0300 Subject: [PATCH 02/10] Fix Options --- .../Extensions/ServiceCollectionExtensions.cs | 2 +- ...thServerPasswordPolicyOptionsValidator.cs} | 19 +++++++------ ...sswordPolicyServiceCollectionExtensions.cs | 21 -------------- .../Credentials/PasswordPolicyOptionsTests.cs | 28 +++++++++++++------ 4 files changed, 30 insertions(+), 40 deletions(-) rename src/{credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyOptionsValidator.cs => CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerPasswordPolicyOptionsValidator.cs} (76%) diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index f67c241c..8dfa422d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -138,6 +138,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.AddSingleton, UAuthServerUserIdentifierOptionsValidator>(); services.AddSingleton, UAuthServerSessionResolutionOptionsValidator>(); services.AddSingleton, UAuthServerPaginationOptionsValidator>(); + services.AddSingleton, UAuthServerPasswordPolicyOptionsValidator>(); services.AddScoped(); services.TryAddSingleton(sp => @@ -374,7 +375,6 @@ internal static IServiceCollection AddCredentialsInternal(IServiceCollection ser services.TryAddScoped(); services.AddPasswordStrengthPolicy(); - services.AddPasswordPolicyOptions(); return services; } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerPasswordPolicyOptionsValidator.cs similarity index 76% rename from src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyOptionsValidator.cs rename to src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerPasswordPolicyOptionsValidator.cs index 284da376..c9824e34 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerPasswordPolicyOptionsValidator.cs @@ -1,11 +1,12 @@ using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; using Microsoft.Extensions.Options; -namespace CodeBeam.UltimateAuth.Credentials.Options; +namespace CodeBeam.UltimateAuth.Server.Options; -public sealed class PasswordPolicyOptionsValidator : IValidateOptions +public sealed class UAuthServerPasswordPolicyOptionsValidator : IValidateOptions { - public ValidateOptionsResult Validate(string? name, PasswordPolicyOptions options) + public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) { var errors = new List(); @@ -14,7 +15,7 @@ public ValidateOptionsResult Validate(string? name, PasswordPolicyOptions option return ValidateOptionsResult.Fail("Password policy options cannot be null."); } - if (options.Level is not ( + if (options.Password.Level is not ( PasswordPolicyLevel.Basic or PasswordPolicyLevel.Standard or PasswordPolicyLevel.Strict)) @@ -22,21 +23,21 @@ PasswordPolicyLevel.Standard or errors.Add("Password policy Level must be Basic, Standard or Strict."); } - if (!IsValidCheckLevel(options.CommonPasswords)) + if (!IsValidCheckLevel(options.Password.CommonPasswords)) { errors.Add("Invalid CommonPasswords level."); } - if (!IsValidCheckLevel(options.UserSimilarity)) + if (!IsValidCheckLevel(options.Password.UserSimilarity)) { errors.Add("Invalid UserSimilarity level."); } - if (options.History is null) + if (options.Password.History is null) { errors.Add("Password History options cannot be null."); } - else if (options.History.RememberLast < 0 || options.History.RememberLast > PasswordPolicyLimits.MaxHistoryCount) + else if (options.Password.History.RememberLast < 0 || options.Password.History.RememberLast > PasswordPolicyLimits.MaxHistoryCount) { errors.Add("Password history count must be between 0 and 10."); } @@ -44,7 +45,7 @@ PasswordPolicyLevel.Standard or if (errors.Count > 0) return ValidateOptionsResult.Fail(errors); - var effective = PasswordPolicyOptionsResolver.Resolve(options); + var effective = PasswordPolicyOptionsResolver.Resolve(options.Password); if (effective.MinLength < PasswordPolicyLimits.MinimumLength) { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Extensions/PasswordPolicyServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Extensions/PasswordPolicyServiceCollectionExtensions.cs index e45de57a..569a33ea 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Extensions/PasswordPolicyServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Extensions/PasswordPolicyServiceCollectionExtensions.cs @@ -18,27 +18,6 @@ public static IServiceCollection AddPasswordStrengthPolicy(this IServiceCollecti return services; } - public static IServiceCollection AddPasswordPolicyOptions(this IServiceCollection services, Action? configure = null) - { - var builder = services.AddOptions(); - - if (configure is not null) - builder.Configure(configure); - - builder.ValidateOnStart(); - - services.TryAddSingleton, PasswordPolicyOptionsValidator>(); - - services.TryAddSingleton(sp => - { - var options = sp.GetRequiredService>().Value; - - return PasswordPolicyOptionsResolver.Resolve(options); - }); - - return services; - } - public static IServiceCollection ReplacePasswordStrengthPolicy(this IServiceCollection services) where TPolicy : class, IPasswordStrengthPolicy { services.Replace(ServiceDescriptor.Scoped()); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordPolicyOptionsTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordPolicyOptionsTests.cs index 87a8bec1..8b755fa5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordPolicyOptionsTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordPolicyOptionsTests.cs @@ -1,11 +1,12 @@ using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Options; +using CodeBeam.UltimateAuth.Server.Options; namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials; public sealed class PasswordPolicyOptionsTests { - private readonly PasswordPolicyOptionsValidator _validator = new(); + private readonly UAuthServerPasswordPolicyOptionsValidator _validator = new(); [Fact] public void DefaultOptions_ResolveToStandard() @@ -61,7 +62,10 @@ public void InvalidGlobalLevel_IsRejected(PasswordPolicyLevel level) { var result = _validator.Validate( null, - new PasswordPolicyOptions { Level = level }); + new UAuthServerOptions + { + Password = new PasswordPolicyOptions { Level = level } + }); Assert.True(result.Failed); } @@ -74,10 +78,13 @@ public void InvalidLengths_AreRejected(int min, int max) { var result = _validator.Validate( null, - new PasswordPolicyOptions + new UAuthServerOptions { - MinLength = min, - MaxLength = max + Password = new PasswordPolicyOptions + { + MinLength = min, + MaxLength = max + } }); Assert.True(result.Failed); @@ -90,11 +97,14 @@ public void InvalidHistoryCount_IsRejected(int count) { var result = _validator.Validate( null, - new PasswordPolicyOptions + new UAuthServerOptions { - History = new PasswordHistoryOptions + Password = new PasswordPolicyOptions { - RememberLast = count + History = new PasswordHistoryOptions + { + RememberLast = count + } } }); @@ -109,7 +119,7 @@ public void DisabledIndividualCheck_IsAllowed() UserSimilarity = PasswordPolicyLevel.Disabled }; - var result = _validator.Validate(null, options); + var result = _validator.Validate(null, new UAuthServerOptions { Password = options }); Assert.True(result.Succeeded); } From c2ea79432e03833d82193022b33bd88d453b28a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 2 Nov 2026 00:46:42 +0300 Subject: [PATCH 03/10] Fix Effective Options --- .../PasswordUserLifecycleIntegration.cs | 11 ++++++++++- .../DefaultPasswordValidationPipeline.cs | 4 ++-- .../Policies/IPasswordValidationPipeline.cs | 3 ++- .../Services/CredentialManagementService.cs | 16 +++++++++++++++- .../Services/PasswordCredentialWriter.cs | 9 +++++---- 5 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs index 13c3c5dd..50599072 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs @@ -2,8 +2,11 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; using CodeBeam.UltimateAuth.Credentials.Policies; +using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Users; using CodeBeam.UltimateAuth.Users.Contracts; @@ -16,15 +19,17 @@ internal sealed class PasswordUserLifecycleIntegration : IUserLifecycleIntegrati private readonly PasswordCredentialWriter _passwordWriter; private readonly IPasswordHistoryStoreFactory _historyStores; private readonly IPasswordPolicyContextProvider _passwordContextProvider; + private readonly IEffectiveServerOptionsProvider _effectiveServerOptionsProvider; private readonly IClock _clock; - public PasswordUserLifecycleIntegration(IPasswordCredentialStoreFactory credentialStoreFactory, IUAuthPasswordHasher passwordHasher, PasswordCredentialWriter passwordWriter, IPasswordHistoryStoreFactory historyStores, IPasswordPolicyContextProvider passwordContextProvider, IClock clock) + public PasswordUserLifecycleIntegration(IPasswordCredentialStoreFactory credentialStoreFactory, IUAuthPasswordHasher passwordHasher, PasswordCredentialWriter passwordWriter, IPasswordHistoryStoreFactory historyStores, IPasswordPolicyContextProvider passwordContextProvider, IEffectiveServerOptionsProvider effectiveServerOptionsProvider, IClock clock) { _credentialStoreFactory = credentialStoreFactory; _passwordHasher = passwordHasher; _passwordWriter = passwordWriter; _historyStores = historyStores; _passwordContextProvider = passwordContextProvider; + _effectiveServerOptionsProvider = effectiveServerOptionsProvider; _clock = clock; } @@ -37,11 +42,15 @@ public async Task OnUserCreatedAsync(TenantKey tenant, UserKey userKey, object r return; var passwordContext = await _passwordContextProvider.CreateAsync(tenant, userKey, r.Password, ct); + // TODO: This is not correct client profile. Resolve true client profile in the future. + var effectiveServer = _effectiveServerOptionsProvider.GetEffective(tenant, UAuthClientProfile.NotSpecified); + var passwordOptions = PasswordPolicyOptionsResolver.Resolve(effectiveServer.Options.Password); await _passwordWriter.CreateAsync( tenant, userKey, r.Password, passwordContext, + passwordOptions, _clock.UtcNow, ct); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/DefaultPasswordValidationPipeline.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/DefaultPasswordValidationPipeline.cs index 92c2669f..37113b70 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/DefaultPasswordValidationPipeline.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/DefaultPasswordValidationPipeline.cs @@ -4,9 +4,9 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference; -public sealed class DefaultPasswordValidationPipeline(IPasswordStrengthPolicy strengthPolicy, IPasswordHistoryValidator historyValidator, EffectivePasswordPolicyOptions options) : IPasswordValidationPipeline +public sealed class DefaultPasswordValidationPipeline(IPasswordStrengthPolicy strengthPolicy, IPasswordHistoryValidator historyValidator) : IPasswordValidationPipeline { - public async Task ValidateAsync(PasswordValidationRequest request, CancellationToken ct = default) + public async Task ValidateAsync(PasswordValidationRequest request, EffectivePasswordPolicyOptions options, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request.Context); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/IPasswordValidationPipeline.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/IPasswordValidationPipeline.cs index 27041fd9..2cfb3af7 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/IPasswordValidationPipeline.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/IPasswordValidationPipeline.cs @@ -1,8 +1,9 @@ using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; namespace CodeBeam.UltimateAuth.Credentials.Reference; public interface IPasswordValidationPipeline { - Task ValidateAsync(PasswordValidationRequest request, CancellationToken ct = default); + Task ValidateAsync(PasswordValidationRequest request, EffectivePasswordPolicyOptions options, CancellationToken ct = default); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs index 01244d5a..6e20b363 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -5,6 +5,7 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; using CodeBeam.UltimateAuth.Credentials.Policies; using CodeBeam.UltimateAuth.Credentials.Reference.Internal; using CodeBeam.UltimateAuth.Server.Infrastructure; @@ -32,6 +33,7 @@ internal sealed class CredentialManagementService : ICredentialManagementService private readonly PasswordCredentialWriter _passwordWriter; private readonly IPasswordHistoryStoreFactory _historyStores; private readonly IPasswordPolicyContextProvider _passwordContextProvider; + private readonly IEffectiveServerOptionsProvider _effectiveServerOptionsProvider; private readonly UAuthServerOptions _options; private readonly IClock _clock; @@ -51,6 +53,7 @@ public CredentialManagementService( PasswordCredentialWriter passwordWriter, IPasswordHistoryStoreFactory historyStores, IPasswordPolicyContextProvider passwordContextProvider, + IEffectiveServerOptionsProvider effectiveOptionsProvider, IOptions options, IClock clock) { @@ -69,6 +72,7 @@ public CredentialManagementService( _passwordWriter = passwordWriter; _historyStores = historyStores; _passwordContextProvider = passwordContextProvider; + _effectiveServerOptionsProvider = effectiveOptionsProvider; _options = options.Value; _clock = clock; } @@ -116,11 +120,16 @@ public async Task AddAsync(AccessContext context, AddCreden var subjectUser = context.GetTargetUserKey(); var passwordContext = await _passwordContextProvider.CreateAsync(context.ResourceTenant, subjectUser, request.Secret, atomicCt); + // TODO: This is not correct client profile. Resolve true client profile in the future. + var effectiveServer = _effectiveServerOptionsProvider.GetEffective(context.ResourceTenant, UAuthClientProfile.NotSpecified); + var passwordOptions = PasswordPolicyOptionsResolver.Resolve(effectiveServer.Options.Password); + var credential = await _passwordWriter.CreateAsync( context.ResourceTenant, subjectUser, request.Secret, passwordContext, + passwordOptions, _clock.UtcNow, atomicCt); @@ -164,7 +173,9 @@ public async Task ChangeSecretAsync(AccessContext contex } var passwordContext = await _passwordContextProvider.CreateAsync(context.ResourceTenant, subjectUser, request.NewSecret, atomicCt); - await _passwordWriter.ChangeAsync(pwd, request.NewSecret, passwordContext, now, atomicCt); + var effectiveServer = _effectiveServerOptionsProvider.GetEffective(context.ResourceTenant, UAuthClientProfile.NotSpecified); + var passwordOptions = PasswordPolicyOptionsResolver.Resolve(effectiveServer.Options.Password); + await _passwordWriter.ChangeAsync(pwd, request.NewSecret, passwordContext, passwordOptions, now, atomicCt); await ApplyCredentialSessionPolicyAsync(context.ResourceTenant, subjectUser, context.IsSelfAction ? CredentialChangeReason.SelfChange @@ -409,10 +420,13 @@ public async Task CompleteResetAsync(AccessContext conte // Strength + current password + history validation. // Validation failure throws before token consumption. var passwordContext = await _passwordContextProvider.CreateAsync(context.ResourceTenant, userKey, request.NewSecret, atomicCt); + var effectiveServer = _effectiveServerOptionsProvider.GetEffective(context.ResourceTenant, UAuthClientProfile.NotSpecified); + var passwordOptions = PasswordPolicyOptionsResolver.Resolve(effectiveServer.Options.Password); await _passwordWriter.ChangeAsync( currentPwd, request.NewSecret, passwordContext, + passwordOptions, now, atomicCt); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/PasswordCredentialWriter.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/PasswordCredentialWriter.cs index 129aa251..ec5cfd5f 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/PasswordCredentialWriter.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/PasswordCredentialWriter.cs @@ -12,10 +12,9 @@ internal sealed class PasswordCredentialWriter( IPasswordValidationPipeline pipeline, IPasswordCredentialStoreFactory credentialStores, IPasswordHistoryStoreFactory historyStores, - IUAuthPasswordHasher hasher, - EffectivePasswordPolicyOptions options) + IUAuthPasswordHasher hasher) { - public async Task CreateAsync(TenantKey tenant, UserKey userKey, string password, PasswordPolicyContext context, + public async Task CreateAsync(TenantKey tenant, UserKey userKey, string password, PasswordPolicyContext context, EffectivePasswordPolicyOptions options, DateTimeOffset now, CancellationToken ct) { var result = await pipeline.ValidateAsync( @@ -23,6 +22,7 @@ public async Task CreateAsync(TenantKey tenant, UserKey user { Context = context with { Password = password } }, + options, ct); EnsureValid(result); @@ -41,7 +41,7 @@ public async Task CreateAsync(TenantKey tenant, UserKey user return credential; } - public async Task ChangeAsync(PasswordCredential credential, string newPassword, PasswordPolicyContext context, + public async Task ChangeAsync(PasswordCredential credential, string newPassword, PasswordPolicyContext context, EffectivePasswordPolicyOptions options, DateTimeOffset now, CancellationToken ct) { var key = new CredentialKey(credential.Tenant, credential.Id); @@ -55,6 +55,7 @@ public async Task ChangeAsync(PasswordCredential credential, }, ExistingCredentialKey = key }, + options, ct); EnsureValid(result); From 396f2e18546c8f08d8337f83155e457e6ee9c9a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 2 Nov 2026 01:04:08 +0300 Subject: [PATCH 04/10] Fix Some Tests --- .../Services/CredentialManagementService.cs | 22 ++- .../CredentialManagementServiceTests.cs | 115 +++++++++++- .../PasswordValidationPipelineTests.cs | 173 +++++++++++++++--- .../Credentials/ResetPasswordTests.cs | 39 +++- 4 files changed, 306 insertions(+), 43 deletions(-) diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs index 6e20b363..a686361f 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -422,13 +422,21 @@ public async Task CompleteResetAsync(AccessContext conte var passwordContext = await _passwordContextProvider.CreateAsync(context.ResourceTenant, userKey, request.NewSecret, atomicCt); var effectiveServer = _effectiveServerOptionsProvider.GetEffective(context.ResourceTenant, UAuthClientProfile.NotSpecified); var passwordOptions = PasswordPolicyOptionsResolver.Resolve(effectiveServer.Options.Password); - await _passwordWriter.ChangeAsync( - currentPwd, - request.NewSecret, - passwordContext, - passwordOptions, - now, - atomicCt); + + try + { + await _passwordWriter.ChangeAsync( + currentPwd, + request.NewSecret, + passwordContext, + passwordOptions, + now, + atomicCt); + } + catch (UAuthValidationException) when (context.IsAnonymousContext) + { + return CredentialActionResult.Success(); + } var consumed = currentState.ConsumeReset(now); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs index 8d13b662..3dc8cdf5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs @@ -12,7 +12,9 @@ using CodeBeam.UltimateAuth.Credentials.Options; using CodeBeam.UltimateAuth.Credentials.Policies; using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using CodeBeam.UltimateAuth.Users; using CodeBeam.UltimateAuth.Users.Contracts; @@ -243,6 +245,18 @@ public async Task ChangeSecretAsync_WhenNewSecretMatchesCurrent_ThrowsValidation "same-password")) .Returns(true); + f.PasswordPipeline + .Setup(x => x.ValidateAsync( + It.Is(r => + r.Context.Password == "same-password" && + r.ExistingCredentialKey.HasValue), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(PasswordPolicyResult.Failure( + new PasswordPolicyViolation( + PasswordPolicyErrorCodes.ReusedPassword, + "Password was previously used."))); + var act = () => f.Sut.ChangeSecretAsync( context, new ChangeCredentialRequest @@ -254,7 +268,7 @@ public async Task ChangeSecretAsync_WhenNewSecretMatchesCurrent_ThrowsValidation var exception = await act.Should() .ThrowAsync(); - exception.Which.Code.Should().Be("credential_secret_same"); + exception.Which.Code.Should().Be(PasswordPolicyErrorCodes.ReusedPassword); f.CredentialStore.Verify( x => x.SaveAsync( @@ -447,7 +461,7 @@ public async Task ChangeSecretAsync_Admin_WhenValid_DoesNotRequireCurrentSecret_ f.Hasher.Verify(x => x.Verify( f.OldPasswordHash, It.IsAny()), - Times.Once); + Times.Never); f.SessionStore.Verify(x => x.RevokeAllChainsAsync( target, @@ -462,6 +476,14 @@ public async Task ChangeSecretAsync_Admin_WhenValid_DoesNotRequireCurrentSecret_ It.IsAny>(), It.IsAny()), Times.Once); + + f.PasswordPipeline.Verify( + x => x.ValidateAsync( + It.Is(r => + r.ExistingCredentialKey.HasValue), + It.IsAny(), + It.IsAny()), + Times.Once); } // --------------------------------------------------------- @@ -880,6 +902,7 @@ public async Task AddAsync_WhenPasswordPolicyRejects_DoesNotHashOrPersist() .Setup(x => x.ValidateAsync( It.Is(r => r.Context.Password == "weak"), + It.IsAny(), It.IsAny())) .ReturnsAsync(PasswordPolicyResult.Failure( new PasswordPolicyViolation( @@ -940,6 +963,7 @@ public async Task ChangeSecretAsync_WhenPolicyRejects_DoesNotSaveOrRevokeSession .Setup(x => x.ValidateAsync( It.Is(r => r.Context.Password == "weak"), + It.IsAny(), It.IsAny())) .ReturnsAsync(PasswordPolicyResult.Failure( new PasswordPolicyViolation( @@ -1039,6 +1063,60 @@ public async Task DeleteAsync_Hard_RemovesPasswordHistory() Times.Once); } + [Fact] + public async Task AddAsync_UsesEffectivePasswordPolicyOptions() + { + var f = CreateFixture(); + var user = UserKey.New(); + + var context = TestAccessContext.ForUser( + user, + UAuthActions.Credentials.AddSelf); + + f.EffectiveOptionsProvider + .Setup(x => x.GetEffective( + context.ResourceTenant, + UAuthClientProfile.NotSpecified)) + .Returns(new EffectiveUAuthServerOptions + { + Mode = UAuthMode.PureOpaque, + Options = new UAuthServerOptions + { + Password = new PasswordPolicyOptions + { + Level = PasswordPolicyLevel.Strict + } + } + }); + + f.Hasher + .Setup(x => x.Hash("ValidPassword123!")) + .Returns(f.NewPasswordHash); + + f.CredentialStore + .Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + await f.Sut.AddAsync( + context, + new AddCredentialRequest + { + Secret = "ValidPassword123!" + }); + + f.PasswordPipeline.Verify( + x => x.ValidateAsync( + It.Is(r => + r.Context.Password == "ValidPassword123!"), + It.Is(o => + o.Level == PasswordPolicyLevel.Strict && + o.MinLength == 15), + It.IsAny()), + Times.Once); + } + // --------------------------------------------------------- // Helpers @@ -1171,6 +1249,9 @@ private sealed class Fixture public Mock PasswordPipeline { get; } = new(MockBehavior.Strict); + public Mock EffectiveOptionsProvider { get; } + = new(MockBehavior.Strict); + public Mock Clock { get; } = new(MockBehavior.Strict); @@ -1292,6 +1373,19 @@ public Fixture() .Setup(x => x.Create(It.IsAny())) .Returns(HistoryStore.Object); + HistoryStore + .Setup(x => x.PruneAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + HistoryStore + .Setup(x => x.DeleteByCredentialAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + PasswordContextProvider .Setup(x => x.CreateAsync( It.IsAny(), @@ -1304,23 +1398,33 @@ public Fixture() { Password = password })); + PasswordPipeline .Setup(x => x.ValidateAsync( It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(PasswordPolicyResult.Success()); var options = Options.Create( TestServerOptions.Default()); - var effectiveOptions = PasswordPolicyOptionsResolver.Resolve(new PasswordPolicyOptions()); + EffectiveOptionsProvider + .Setup(x => x.GetEffective( + It.IsAny(), + UAuthClientProfile.NotSpecified)) + .Returns((TenantKey tenant, UAuthClientProfile profile) => + new EffectiveUAuthServerOptions + { + Mode = UAuthMode.PureOpaque, + Options = options.Value.Clone() + }); var passwordWriter = new PasswordCredentialWriter( PasswordPipeline.Object, CredentialStoreFactory.Object, HistoryStoreFactory.Object, - Hasher.Object, - effectiveOptions); + Hasher.Object); Sut = new CredentialManagementService( AccessOrchestrator.Object, @@ -1338,6 +1442,7 @@ public Fixture() passwordWriter, HistoryStoreFactory.Object, PasswordContextProvider.Object, + EffectiveOptionsProvider.Object, options, Clock.Object); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordValidationPipelineTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordValidationPipelineTests.cs index 8270dd2f..1b8358b6 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordValidationPipelineTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordValidationPipelineTests.cs @@ -1,14 +1,25 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Options; using CodeBeam.UltimateAuth.Credentials.Policies; using CodeBeam.UltimateAuth.Credentials.Reference; +using Xunit; + +namespace CodeBeam.UltimateAuth.Tests.Unit; public sealed class PasswordValidationPipelineTests { private sealed class AcceptAllStrengthPolicy : IPasswordStrengthPolicy { - public Task ValidateAsync(PasswordPolicyContext context, EffectivePasswordPolicyOptions options, CancellationToken ct = default) + public int CallCount { get; private set; } + + public Task ValidateAsync( + PasswordPolicyContext context, + EffectivePasswordPolicyOptions options, + CancellationToken ct = default) { + CallCount++; + return Task.FromResult(PasswordPolicyResult.Success()); } } @@ -17,39 +28,85 @@ private sealed class RecordingHistoryValidator : IPasswordHistoryValidator { public int CallCount { get; private set; } - public Task ValidateAsync(CredentialKey credentialKey, string password, int rememberLast, CancellationToken ct = default) + public CredentialKey? LastCredentialKey { get; private set; } + + public string? LastPassword { get; private set; } + + public int? LastRememberLast { get; private set; } + + public PasswordPolicyResult Result { get; set; } + = PasswordPolicyResult.Success(); + + public Task ValidateAsync( + CredentialKey credentialKey, + string password, + int rememberLast, + CancellationToken ct = default) { CallCount++; + LastCredentialKey = credentialKey; + LastPassword = password; + LastRememberLast = rememberLast; - return Task.FromResult(PasswordPolicyResult.Failure(new PasswordPolicyViolation(PasswordPolicyErrorCodes.ReusedPassword, "Password was used previously."))); + return Task.FromResult(Result); } } - private static DefaultPasswordValidationPipeline CreatePipeline(RecordingHistoryValidator history) + private static EffectivePasswordPolicyOptions CreateOptions( + int rememberLast = 0) + { + return PasswordPolicyOptionsResolver.Resolve( + new PasswordPolicyOptions + { + Level = PasswordPolicyLevel.Standard, + History = new PasswordHistoryOptions + { + RememberLast = rememberLast + } + }); + } + + private static DefaultPasswordValidationPipeline CreatePipeline( + RecordingHistoryValidator history, + AcceptAllStrengthPolicy? strength = null) + { + return new DefaultPasswordValidationPipeline( + strength ?? new AcceptAllStrengthPolicy(), + history); + } + + private static PasswordValidationRequest CreateRequest( + string password, + CredentialKey? existingCredentialKey = null) { - return new DefaultPasswordValidationPipeline(new AcceptAllStrengthPolicy(), history, PasswordPolicyOptionsResolver.Resolve(new())); + return new PasswordValidationRequest + { + Context = new PasswordPolicyContext + { + Password = password + }, + ExistingCredentialKey = existingCredentialKey + }; } [Fact] public async Task AbsoluteLimit_CannotBeBypassedByCustomStrengthPolicy() { var history = new RecordingHistoryValidator(); - var pipeline = CreatePipeline(history); + var strength = new AcceptAllStrengthPolicy(); + var pipeline = CreatePipeline(history, strength); var result = await pipeline.ValidateAsync( - new PasswordValidationRequest - { - Context = new PasswordPolicyContext - { - Password = new string('a', 257) - } - }); + CreateRequest(new string('a', 257)), + CreateOptions()); Assert.False(result.Succeeded); + Assert.Contains( result.Violations, x => x.Code == PasswordPolicyErrorCodes.InputTooLong); + Assert.Equal(0, strength.CallCount); Assert.Equal(0, history.CallCount); } @@ -57,18 +114,90 @@ public async Task AbsoluteLimit_CannotBeBypassedByCustomStrengthPolicy() public async Task NewCredential_DoesNotRequireHistory() { var history = new RecordingHistoryValidator(); - var pipeline = CreatePipeline(history); + var strength = new AcceptAllStrengthPolicy(); + var pipeline = CreatePipeline(history, strength); var result = await pipeline.ValidateAsync( - new PasswordValidationRequest - { - Context = new PasswordPolicyContext - { - Password = "NewPassword123!" - } - }); + CreateRequest("NewPassword123!"), + CreateOptions(rememberLast: 5)); Assert.True(result.Succeeded); + Assert.Equal(1, strength.CallCount); Assert.Equal(0, history.CallCount); } + + [Fact] + public async Task ExistingCredential_AlwaysValidatesHistory_WhenRememberLastIsZero() + { + var history = new RecordingHistoryValidator + { + Result = PasswordPolicyResult.Failure( + new PasswordPolicyViolation( + PasswordPolicyErrorCodes.ReusedPassword, + "Password matches the current password.")) + }; + + var pipeline = CreatePipeline(history); + var key = new CredentialKey(TenantKey.Single, Guid.NewGuid()); + + var result = await pipeline.ValidateAsync( + CreateRequest("CurrentPassword123!", key), + CreateOptions(rememberLast: 0)); + + Assert.False(result.Succeeded); + + Assert.Contains( + result.Violations, + x => x.Code == PasswordPolicyErrorCodes.ReusedPassword); + + Assert.Equal(1, history.CallCount); + Assert.Equal(0, history.LastRememberLast); + Assert.Equal(key, history.LastCredentialKey); + } + + [Fact] + public async Task ExistingCredential_PassesRememberLastToHistoryValidator() + { + var history = new RecordingHistoryValidator(); + var pipeline = CreatePipeline(history); + var key = new CredentialKey(TenantKey.Single, Guid.NewGuid()); + + var result = await pipeline.ValidateAsync( + CreateRequest("NewPassword123!", key), + CreateOptions(rememberLast: 5)); + + Assert.True(result.Succeeded); + Assert.Equal(1, history.CallCount); + Assert.Equal(key, history.LastCredentialKey); + Assert.Equal("NewPassword123!", history.LastPassword); + Assert.Equal(5, history.LastRememberLast); + } + + [Fact] + public async Task ExistingCredential_PropagatesHistoryValidationFailure() + { + var history = new RecordingHistoryValidator + { + Result = PasswordPolicyResult.Failure( + new PasswordPolicyViolation( + PasswordPolicyErrorCodes.ReusedPassword, + "Password was used previously.")) + }; + + var pipeline = CreatePipeline(history); + var key = new CredentialKey(TenantKey.Single, Guid.NewGuid()); + + var result = await pipeline.ValidateAsync( + CreateRequest("PreviouslyUsed123!", key), + CreateOptions(rememberLast: 3)); + + Assert.False(result.Succeeded); + + Assert.Contains( + result.Violations, + x => x.Code == PasswordPolicyErrorCodes.ReusedPassword); + + Assert.Equal(1, history.CallCount); + Assert.Equal(3, history.LastRememberLast); + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs index 52aafab0..faf3ed15 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs @@ -110,27 +110,48 @@ public async Task Reset_password_with_valid_token_should_succeed() } [Fact] - public async Task Reset_password_with_same_password_should_succeed_without_changing_password() + public async Task Reset_password_with_same_password_should_fail_without_consuming_token() { var (runtime, notifier) = CreateRuntime(); var service = runtime.GetCredentialManagementService(); + var userKey = await GetAdminUserKeyAsync(runtime); + var token = await BeginAndCaptureAsync( runtime, notifier, ResetCodeType.Token); - var result = await service.CompleteResetAsync( + // The existing password must be rejected. + var exception = await Assert.ThrowsAsync( + () => service.CompleteResetAsync( + CompleteContext(), + CompleteRequest(token, "admin"))); + + exception.Should().NotBeNull(); + + await AssertPasswordAsync(runtime, userKey, "admin"); + + // Validation failure must not consume the reset token. + var retry = await service.CompleteResetAsync( CompleteContext(), - CompleteRequest(token, "admin")); + CompleteRequest(token, "DifferentPassword123!")); - result.IsSuccess.Should().BeTrue(); + retry.IsSuccess.Should().BeTrue(); - // The reset token must be single-use even when - // the new password matches the existing password. - var secondResult = await service.CompleteResetAsync( + await AssertPasswordAsync( + runtime, userKey, "DifferentPassword123!"); + + // Anonymous reset returns generic success even for a consumed token. + var replay = await service.CompleteResetAsync( CompleteContext(), - CompleteRequest(token, "different-password")); + CompleteRequest(token, "AnotherPassword123!")); + + replay.IsSuccess.Should().BeTrue(); + + await AssertPasswordAsync( + runtime, userKey, "DifferentPassword123!"); - secondResult.IsSuccess.Should().BeTrue(); + await AssertPasswordRejectedAsync( + runtime, userKey, "AnotherPassword123!"); } [Fact] From 354d8b24d0241475c20f30135220ec3c587d0173 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 2 Nov 2026 09:21:57 +0300 Subject: [PATCH 05/10] Fix Remaining Tests --- ...1102060817_003_PasswordHistory.Designer.cs | 778 ++++++++++++++++++ .../20261102060817_003_PasswordHistory.cs | 48 ++ .../Migrations/UAuthDbContextModelSnapshot.cs | 36 + .../Services/LabUserProvisioningService.cs | 27 +- ...1102060746_003_PasswordHistory.Designer.cs | 765 +++++++++++++++++ .../20261102060746_003_PasswordHistory.cs | 48 ++ .../Migrations/UAuthDbContextModelSnapshot.cs | 36 + ...1102060652_003_PasswordHistory.Designer.cs | 765 +++++++++++++++++ .../20261102060652_003_PasswordHistory.cs | 48 ++ .../Migrations/UAuthDbContextModelSnapshot.cs | 36 + .../RegistrationIntegrationTests.cs | 10 +- .../Integration/UserWorkflowTests.cs | 15 +- .../Credentials/ResetPasswordTests.cs | 19 +- 13 files changed, 2592 insertions(+), 39 deletions(-) create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261102060817_003_PasswordHistory.Designer.cs create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261102060817_003_PasswordHistory.cs create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261102060746_003_PasswordHistory.Designer.cs create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261102060746_003_PasswordHistory.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261102060652_003_PasswordHistory.Designer.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261102060652_003_PasswordHistory.cs diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261102060817_003_PasswordHistory.Designer.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261102060817_003_PasswordHistory.Designer.cs new file mode 100644 index 00000000..583c63e0 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261102060817_003_PasswordHistory.Designer.cs @@ -0,0 +1,778 @@ +// +using System; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace CodeBeam.UltimateAuth.IdentityLab.Migrations +{ + [DbContext(typeof(UAuthDbContext))] + [Migration("20261102060817_003_PasswordHistory")] + partial class _003_PasswordHistory + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.AuthenticationSecurityStateProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CredentialType") + .HasColumnType("integer"); + + b.Property("FailedAttempts") + .HasColumnType("integer"); + + b.Property("LastFailedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("RequiresReauthentication") + .HasColumnType("boolean"); + + b.Property("ResetAttempts") + .HasColumnType("integer"); + + b.Property("ResetConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetRequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetTokenHash") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Scope") + .HasColumnType("integer"); + + b.Property("SecurityVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + 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("character varying(128)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("Permission") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + 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("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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("character varying(128)"); + + b.Property("UserKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + 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("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.Property("Source") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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.Credentials.EntityFrameworkCore.PasswordHistoryProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CredentialId") + .HasColumnType("uuid"); + + b.Property("ReplacedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "CredentialId", "ReplacedAt", "Id"); + + b.ToTable("UAuth_PasswordHistory", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AbsoluteExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActiveSessionId") + .HasColumnType("text"); + + b.Property("ChainId") + .HasColumnType("uuid"); + + b.Property("ClaimsSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Device") + .IsRequired() + .HasColumnType("text"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RootId") + .HasColumnType("uuid"); + + b.Property("RotationCount") + .HasColumnType("integer"); + + b.Property("SecurityVersionAtCreation") + .HasColumnType("bigint"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TouchCount") + .HasColumnType("integer"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChainId") + .HasColumnType("uuid"); + + b.Property("Claims") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Device") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityVersionAtCreation") + .HasColumnType("bigint"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RootId") + .HasMaxLength(128) + .HasColumnType("uuid"); + + b.Property("SecurityVersion") + .HasColumnType("bigint"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "RootId") + .IsUnique(); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "RevokedAt"); + + b.ToTable("UAuth_SessionRoots", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.RefreshTokenProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChainId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TokenId") + .HasColumnType("uuid"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("NormalizedValue") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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", "DeletedAt", "CreatedAt", "Id"); + + b.ToTable("UAuth_UserIdentifiers", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserLifecycleProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityVersion") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey") + .IsUnique(); + + b.HasIndex("Tenant", "DeletedAt", "CreatedAt", "Id"); + + b.HasIndex("Tenant", "Status", "DeletedAt", "CreatedAt", "Id"); + + b.ToTable("UAuth_UserLifecycles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserProfileProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bio") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Culture") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("FirstName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Gender") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Language") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Metadata") + .HasColumnType("text"); + + b.Property("ProfileKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TimeZone") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261102060817_003_PasswordHistory.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261102060817_003_PasswordHistory.cs new file mode 100644 index 00000000..c00bab6a --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261102060817_003_PasswordHistory.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CodeBeam.UltimateAuth.IdentityLab.Migrations +{ + /// + public partial class _003_PasswordHistory : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UAuth_PasswordHistory", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Tenant = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + UserKey = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + CredentialId = table.Column(type: "uuid", nullable: false), + SecretHash = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + ReplacedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_PasswordHistory", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_PasswordHistory_Tenant_CredentialId_ReplacedAt_Id", + table: "UAuth_PasswordHistory", + columns: new[] { "Tenant", "CredentialId", "ReplacedAt", "Id" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_PasswordHistory_Tenant_UserKey", + table: "UAuth_PasswordHistory", + columns: new[] { "Tenant", "UserKey" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UAuth_PasswordHistory"); + } + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/UAuthDbContextModelSnapshot.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/UAuthDbContextModelSnapshot.cs index 381996b7..55d8efe3 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/UAuthDbContextModelSnapshot.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/UAuthDbContextModelSnapshot.cs @@ -249,6 +249,42 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UAuth_PasswordCredentials", (string)null); }); + modelBuilder.Entity("CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.PasswordHistoryProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CredentialId") + .HasColumnType("uuid"); + + b.Property("ReplacedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "CredentialId", "ReplacedAt", "Id"); + + b.ToTable("UAuth_PasswordHistory", (string)null); + }); + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", b => { b.Property("Id") diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabUserProvisioningService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabUserProvisioningService.cs index 858f630f..91f807b1 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabUserProvisioningService.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabUserProvisioningService.cs @@ -20,26 +20,19 @@ public sealed class LabUserProvisioningService( LabProvisioningRollbackGate rollbackGate, ILogger logger) { - public async Task CreateMemberAsync( - UserKey actorUserKey, - CreateUserRequest request, - CancellationToken ct = default) + public async Task CreateMemberAsync(UserKey actorUserKey, CreateUserRequest request, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(request); // Obtain the actor from the actual authenticated session. - var context = await contextProvider.CreateAsync( - UAuthActions.Users.CreateAdmin, - "users", - ct); + var context = await contextProvider.CreateAsync(UAuthActions.Users.CreateAdmin, "users", ct); if (!context.IsAuthenticated || context.IsSystemActor || context.ActorUserKey is not UserKey authenticatedUserKey || authenticatedUserKey != actorUserKey) { - throw new UnauthorizedAccessException( - "Authenticated actor mismatch."); + throw new UnauthorizedAccessException("Authenticated actor mismatch."); } var sandbox = await sandboxService.GetByUserAsync( @@ -54,16 +47,12 @@ context.ActorUserKey is not UserKey authenticatedUserKey || ct); if (!access.Allowed) - throw new UnauthorizedAccessException( - "Sandbox management permission required."); + throw new UnauthorizedAccessException("Sandbox management permission required."); - var remaining = await sandboxService.GetRemainingCapacityAsync( - sandbox.Id, - ct); + var remaining = await sandboxService.GetRemainingCapacityAsync(sandbox.Id, ct); if (remaining <= 0) - throw new InvalidOperationException( - "Sandbox user limit reached."); + throw new InvalidOperationException("Sandbox user limit reached."); // Fail before account creation if the required role is absent. var roleStore = roleStoreFactory.Create( @@ -76,9 +65,7 @@ context.ActorUserKey is not UserKey authenticatedUserKey || "Identity Lab User role is not configured."); // Only this server-side path can attach the object-identity token. - var trustedContext = context.WithAttribute( - LabProvisioningGate.AttributeKey, - LabProvisioningGate.Token); + var trustedContext = context.WithAttribute(LabProvisioningGate.AttributeKey, LabProvisioningGate.Token); var result = await userApplicationService.CreateUserAsync(trustedContext, request, ct); diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261102060746_003_PasswordHistory.Designer.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261102060746_003_PasswordHistory.Designer.cs new file mode 100644 index 00000000..0bfc6cee --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261102060746_003_PasswordHistory.Designer.cs @@ -0,0 +1,765 @@ +// +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.UAuthHub.EFCore.Migrations +{ + [DbContext(typeof(UAuthDbContext))] + [Migration("20261102060746_003_PasswordHistory")] + partial class _003_PasswordHistory + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + 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.Credentials.EntityFrameworkCore.PasswordHistoryProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasColumnType("TEXT"); + + b.Property("ReplacedAt") + .HasColumnType("TEXT"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "CredentialId", "ReplacedAt", "Id"); + + b.ToTable("UAuth_PasswordHistory", (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"); + + b.HasIndex("Tenant", "UserKey", "RevokedAt"); + + 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", "DeletedAt", "CreatedAt", "Id"); + + 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.HasIndex("Tenant", "DeletedAt", "CreatedAt", "Id"); + + b.HasIndex("Tenant", "Status", "DeletedAt", "CreatedAt", "Id"); + + b.ToTable("UAuth_UserLifecycles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserProfileProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("BirthDate") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Culture") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("FirstName") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Gender") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Language") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastName") + .HasMaxLength(128) + .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") + .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", "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/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261102060746_003_PasswordHistory.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261102060746_003_PasswordHistory.cs new file mode 100644 index 00000000..c1baf1b4 --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261102060746_003_PasswordHistory.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore.Migrations +{ + /// + public partial class _003_PasswordHistory : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UAuth_PasswordHistory", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Tenant = table.Column(type: "TEXT", maxLength: 128, nullable: false), + UserKey = table.Column(type: "TEXT", maxLength: 128, nullable: false), + CredentialId = table.Column(type: "TEXT", nullable: false), + SecretHash = table.Column(type: "TEXT", maxLength: 512, nullable: false), + ReplacedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_PasswordHistory", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_PasswordHistory_Tenant_CredentialId_ReplacedAt_Id", + table: "UAuth_PasswordHistory", + columns: new[] { "Tenant", "CredentialId", "ReplacedAt", "Id" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_PasswordHistory_Tenant_UserKey", + table: "UAuth_PasswordHistory", + columns: new[] { "Tenant", "UserKey" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UAuth_PasswordHistory"); + } + } +} diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/UAuthDbContextModelSnapshot.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/UAuthDbContextModelSnapshot.cs index 5e2dd6f8..ceee4996 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/UAuthDbContextModelSnapshot.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/UAuthDbContextModelSnapshot.cs @@ -244,6 +244,42 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UAuth_PasswordCredentials", (string)null); }); + modelBuilder.Entity("CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.PasswordHistoryProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasColumnType("TEXT"); + + b.Property("ReplacedAt") + .HasColumnType("TEXT"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "CredentialId", "ReplacedAt", "Id"); + + b.ToTable("UAuth_PasswordHistory", (string)null); + }); + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", b => { b.Property("Id") diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261102060652_003_PasswordHistory.Designer.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261102060652_003_PasswordHistory.Designer.cs new file mode 100644 index 00000000..3fb236e7 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261102060652_003_PasswordHistory.Designer.cs @@ -0,0 +1,765 @@ +// +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("20261102060652_003_PasswordHistory")] + partial class _003_PasswordHistory + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + 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.Credentials.EntityFrameworkCore.PasswordHistoryProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasColumnType("TEXT"); + + b.Property("ReplacedAt") + .HasColumnType("TEXT"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "CredentialId", "ReplacedAt", "Id"); + + b.ToTable("UAuth_PasswordHistory", (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"); + + b.HasIndex("Tenant", "UserKey", "RevokedAt"); + + 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", "DeletedAt", "CreatedAt", "Id"); + + 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.HasIndex("Tenant", "DeletedAt", "CreatedAt", "Id"); + + b.HasIndex("Tenant", "Status", "DeletedAt", "CreatedAt", "Id"); + + b.ToTable("UAuth_UserLifecycles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserProfileProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("BirthDate") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Culture") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("FirstName") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Gender") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Language") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastName") + .HasMaxLength(128) + .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") + .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", "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/20261102060652_003_PasswordHistory.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261102060652_003_PasswordHistory.cs new file mode 100644 index 00000000..b4a550ae --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261102060652_003_PasswordHistory.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Migrations +{ + /// + public partial class _003_PasswordHistory : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UAuth_PasswordHistory", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Tenant = table.Column(type: "TEXT", maxLength: 128, nullable: false), + UserKey = table.Column(type: "TEXT", maxLength: 128, nullable: false), + CredentialId = table.Column(type: "TEXT", nullable: false), + SecretHash = table.Column(type: "TEXT", maxLength: 512, nullable: false), + ReplacedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_PasswordHistory", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_PasswordHistory_Tenant_CredentialId_ReplacedAt_Id", + table: "UAuth_PasswordHistory", + columns: new[] { "Tenant", "CredentialId", "ReplacedAt", "Id" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_PasswordHistory_Tenant_UserKey", + table: "UAuth_PasswordHistory", + columns: new[] { "Tenant", "UserKey" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UAuth_PasswordHistory"); + } + } +} 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 ff7c7c41..8b10c53a 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 @@ -244,6 +244,42 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UAuth_PasswordCredentials", (string)null); }); + modelBuilder.Entity("CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.PasswordHistoryProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasColumnType("TEXT"); + + b.Property("ReplacedAt") + .HasColumnType("TEXT"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "CredentialId", "ReplacedAt", "Id"); + + b.ToTable("UAuth_PasswordHistory", (string)null); + }); + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", b => { b.Property("Id") diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/RegistrationIntegrationTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/RegistrationIntegrationTests.cs index 51d0566f..20a9b7d7 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/RegistrationIntegrationTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/RegistrationIntegrationTests.cs @@ -26,7 +26,7 @@ public async Task Registration_persists_account_owner_role_and_sandbox_without_l var registration = new LabRegistrationService(ActorProvider.For(null), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), new(db), new(db, NullLogger.Instance)); - var result = await registration.RegisterAsync(new("alice", "alice@example.test", "Alice-test-password-8!")); + var result = await registration.RegisterAsync(new("alice", "alice@example.test", "test-password-8!")); Assert.True(result.IsSuccess, result.ErrorMessage); UserKey owner; await using (var verify = db.CreateDbContext()) @@ -46,7 +46,7 @@ public async Task Registration_persists_account_owner_role_and_sandbox_without_l Assert.NotNull(lifecycle); var duplicate = await Record.ExceptionAsync(async () => { - var duplicateResult = await registration.RegisterAsync(new("alice", "alice@example.test", "Alice-test-password-8!")); + var duplicateResult = await registration.RegisterAsync(new("alice", "alice@example.test", "test-password-8!")); Assert.False(duplicateResult.IsSuccess); }); // Duplicate validation may throw or return failure depending on the framework contract. @@ -108,14 +108,14 @@ public async Task Owner_can_provision_member_and_assign_demo_role_but_cannot_del var assignments = sp.GetRequiredService(); var registration = new LabRegistrationService(ActorProvider.For(null), users, roles, assignments, new(db), new(db, NullLogger.Instance)); - Assert.True((await registration.RegisterAsync(new("owner", "owner@example.test", "Owner-test-password-8!"))).IsSuccess); + Assert.True((await registration.RegisterAsync(new("owner", "owner@example.test", "test-password-8!"))).IsSuccess); UserKey owner; await using (var verify = db.CreateDbContext()) owner = (await verify.Sandboxes.SingleAsync()).OwnerUserKey; var provider = ActorProvider.For(owner); var provisioning = new LabUserProvisioningService(provider, new(db), new(db), users, roles, assignments, sp.GetRequiredService(), NullLogger.Instance); var result = await provisioning.CreateMemberAsync(owner, new CreateUserRequest - { UserName = "member", Email = "member@example.test", Password = "Member-test-password-8!" }); + { UserName = "member", Email = "member@example.test", Password = "test-password-8!" }); Assert.True(result.IsSuccess, result.FailureReason); var member = UserKey.FromString(result.UserKey!); var demos = new LabDemoRoleService(provider, db, roles, assignments); @@ -179,7 +179,7 @@ public async Task Provisioning_assignment_fault_hard_deletes_real_partial_accoun var assignments = sp.GetRequiredService(); var registration = new LabRegistrationService(ActorProvider.For(null), users, roles, assignments, new(db), new(db, NullLogger.Instance)); - Assert.True((await registration.RegisterAsync(new("owner", "owner@example.test", "Owner-test-password-8!"))).IsSuccess); + Assert.True((await registration.RegisterAsync(new("owner", "owner@example.test", "test-password-8!"))).IsSuccess); UserKey owner; await using (var verify = db.CreateDbContext()) owner = (await verify.Sandboxes.SingleAsync()).OwnerUserKey; var before = await db.SnapshotAsync(); diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/UserWorkflowTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/UserWorkflowTests.cs index 205ca000..7a26a678 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/UserWorkflowTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/UserWorkflowTests.cs @@ -23,7 +23,7 @@ private static async Task RegisterAsync(IServiceProvider sp, LabDatabas var registration = new LabRegistrationService(ActorProvider.For(null), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), new(db), new(db, NullLogger.Instance)); - var result = await registration.RegisterAsync(new(name, $"{name}@example.test", "Owner-test-password-8!")); + var result = await registration.RegisterAsync(new(name, $"{name}@example.test", "test-password-8!")); Assert.True(result.IsSuccess, result.ErrorMessage); await using var verify = db.CreateDbContext(); return (await verify.Sandboxes.SingleAsync(x => x.Name == $"{name}'s Sandbox")).OwnerUserKey; @@ -40,8 +40,7 @@ private static SandboxUserService UserService(IServiceProvider sp, LabDatabase d sp.GetRequiredService(), sp.GetRequiredService(), NullLogger.Instance); - private static CreateUserRequest Request(string name) => new() - { UserName = name, Email = $"{name}@example.test", Password = "Member-test-password-8!" }; + private static CreateUserRequest Request(string name) => new() { UserName = name, Email = $"{name}@example.test", Password = "test-password-8!" }; [Theory] [InlineData(false)] @@ -57,16 +56,19 @@ public async Task Created_member_is_queryable_capacity_rejection_leaves_no_accou var foreign = await Provisioning(sp, db, foreignOwner).CreateMemberAsync(foreignOwner, Request("foreign-member")); Assert.True(foreign.IsSuccess, foreign.FailureReason); var foreignKey = UserKey.FromString(foreign.UserKey!); + await using (var setup = db.CreateDbContext()) { var settings = await setup.SandboxSettings.SingleAsync(x => x.Sandbox.OwnerUserKey == owner); settings.MaxUsers = 2; await setup.SaveChangesAsync(); } + var client = new Mock(MockBehavior.Strict); var userClient = new Mock(MockBehavior.Strict); client.SetupGet(x => x.Users).Returns(userClient.Object); var service = UserService(sp, db, owner, client.Object); + await service.CreateAsync(owner, Request("member")); var visible = await service.GetUsersAsync(owner); Assert.NotNull(visible); @@ -87,6 +89,7 @@ public async Task Created_member_is_queryable_capacity_rejection_leaves_no_accou Assert.Equal(beforeRejected, await db.SnapshotAsync()); userClient.VerifyNoOtherCalls(); var application = sp.GetRequiredService(); + if (selfDelete) { userClient.Setup(x => x.DeleteMeAsync()).Returns(async () => @@ -94,8 +97,8 @@ public async Task Created_member_is_queryable_capacity_rejection_leaves_no_accou await application.DeleteMeAsync(Contexts.Create("users.delete.self", member, member)); return new UAuthResult { IsSuccess = true }; }); - Assert.True(await new SandboxAccountDeletionService(db, client.Object, - NullLogger.Instance).DeleteMemberAsync(member)); + + Assert.True(await new SandboxAccountDeletionService(db, client.Object, NullLogger.Instance).DeleteMemberAsync(member)); userClient.Verify(x => x.DeleteMeAsync(), Times.Once); } else @@ -109,6 +112,7 @@ public async Task Created_member_is_queryable_capacity_rejection_leaves_no_accou Assert.True((await service.DeleteAsync(owner, member)).Success); userClient.Verify(x => x.DeleteUserAsync(member, It.IsAny()), Times.Once); } + await using var verification = runtime.Services.CreateAsyncScope(); var lifecycleStore = verification.ServiceProvider.GetRequiredService().Create(TenantKeys.Single); var lifecycle = await lifecycleStore.GetAsync(new UserLifecycleKey(TenantKeys.Single, member)); @@ -129,6 +133,7 @@ public async Task Created_member_is_queryable_capacity_rejection_leaves_no_accou Assert.False(await final.SandboxMembers.AnyAsync(x => x.UserKey == member)); Assert.Equal(2, await final.SandboxMembers.CountAsync(x => x.Sandbox.OwnerUserKey == foreignOwner)); } + [Theory] [InlineData(false)] [InlineData(true)] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs index faf3ed15..c2b92ed7 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs @@ -110,7 +110,7 @@ public async Task Reset_password_with_valid_token_should_succeed() } [Fact] - public async Task Reset_password_with_same_password_should_fail_without_consuming_token() + public async Task Reset_password_with_same_password_should_not_consume_token() { var (runtime, notifier) = CreateRuntime(); var service = runtime.GetCredentialManagementService(); @@ -120,17 +120,18 @@ public async Task Reset_password_with_same_password_should_fail_without_consumin var token = await BeginAndCaptureAsync( runtime, notifier, ResetCodeType.Token); - // The existing password must be rejected. - var exception = await Assert.ThrowsAsync( - () => service.CompleteResetAsync( - CompleteContext(), - CompleteRequest(token, "admin"))); + // Same password is rejected internally, but anonymous + // reset must return a generic success response. + var samePassword = await service.CompleteResetAsync( + CompleteContext(), + CompleteRequest(token, "admin")); - exception.Should().NotBeNull(); + samePassword.IsSuccess.Should().BeTrue(); + // The credential must remain unchanged. await AssertPasswordAsync(runtime, userKey, "admin"); - // Validation failure must not consume the reset token. + // A policy failure must not consume the reset token. var retry = await service.CompleteResetAsync( CompleteContext(), CompleteRequest(token, "DifferentPassword123!")); @@ -140,7 +141,7 @@ public async Task Reset_password_with_same_password_should_fail_without_consumin await AssertPasswordAsync( runtime, userKey, "DifferentPassword123!"); - // Anonymous reset returns generic success even for a consumed token. + // A successfully consumed token must not be reusable. var replay = await service.CompleteResetAsync( CompleteContext(), CompleteRequest(token, "AnotherPassword123!")); From 8309cf25871b679316a4104da3391849f29dc335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 2 Nov 2026 09:59:01 +0300 Subject: [PATCH 06/10] Add New Tests --- .../Extensions/ServiceCollectionExtensions.cs | 2 +- ...or.cs => UAuthPasswordHistoryValidator.cs} | 2 +- ...sswordPolicyServiceCollectionExtensions.cs | 6 +- ... => UAuthPasswordPolicyContextProvider.cs} | 2 +- ...licy.cs => UAuthPasswordStrengthPolicy.cs} | 2 +- ... => UAuthPasswordUserSimilarityChecker.cs} | 2 +- .../EfCorePasswordHistoryAtomicityTests.cs | 177 ++++++ .../PasswordCredentialWriterTests.cs | 317 ++++++++++ .../Credentials/PasswordPolicyOptionsTests.cs | 206 +++++++ ...EfCorePasswordHistoryStoreContractTests.cs | 55 ++ .../IPasswordHistoryStoreTestDatabase.cs | 9 + ...MemoryPasswordHistoryStoreContractTests.cs | 35 ++ .../PasswordHistoryStoreContractTests.cs | 540 ++++++++++++++++++ .../UAuthPasswordHistoryValidatorTests.cs | 331 +++++++++++ ...cs => UAuthPasswordStrengthPolicyTests.cs} | 4 +- .../UAuthPasswordUserSimilarityChecks.cs | 271 +++++++++ 16 files changed, 1951 insertions(+), 10 deletions(-) rename src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/{DefaultPasswordHistoryValidator.cs => UAuthPasswordHistoryValidator.cs} (88%) rename src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/{DefaultPasswordPolicyContextProvider.cs => UAuthPasswordPolicyContextProvider.cs} (83%) rename src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/{DefaultPasswordStrengthPolicy.cs => UAuthPasswordStrengthPolicy.cs} (95%) rename src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/{DefaultPasswordUserSimilarityChecker.cs => UAuthPasswordUserSimilarityChecker.cs} (97%) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/EfCorePasswordHistoryAtomicityTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordCredentialWriterTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/EfCorePasswordHistoryStoreContractTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/IPasswordHistoryStoreTestDatabase.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/InMemoryPasswordHistoryStoreContractTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/PasswordHistoryStoreContractTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordHistoryValidatorTests.cs rename tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/{DefaultPasswordStrengthPolicyTests.cs => UAuthPasswordStrengthPolicyTests.cs} (93%) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordUserSimilarityChecks.cs diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs index f7502d48..bef9cdc2 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs @@ -15,7 +15,7 @@ public static IServiceCollection AddUltimateAuthCredentialsReference(this IServi services.TryAddScoped(); services.TryAddScoped(); services.AddScoped(); - services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/DefaultPasswordHistoryValidator.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/UAuthPasswordHistoryValidator.cs similarity index 88% rename from src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/DefaultPasswordHistoryValidator.cs rename to src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/UAuthPasswordHistoryValidator.cs index 6c7f9141..ba4ef307 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/DefaultPasswordHistoryValidator.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/UAuthPasswordHistoryValidator.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference; -public sealed class DefaultPasswordHistoryValidator(IPasswordCredentialStoreFactory credentialStoreFactory, IPasswordHistoryStoreFactory historyStoreFactory, IUAuthPasswordHasher hasher) : IPasswordHistoryValidator +public sealed class UAuthPasswordHistoryValidator(IPasswordCredentialStoreFactory credentialStoreFactory, IPasswordHistoryStoreFactory historyStoreFactory, IUAuthPasswordHasher hasher) : IPasswordHistoryValidator { public async Task ValidateAsync(CredentialKey credentialKey, string password, int rememberLast, CancellationToken ct = default) { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Extensions/PasswordPolicyServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Extensions/PasswordPolicyServiceCollectionExtensions.cs index 569a33ea..5dc76d34 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Extensions/PasswordPolicyServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Extensions/PasswordPolicyServiceCollectionExtensions.cs @@ -10,10 +10,10 @@ public static class PasswordPolicyServiceCollectionExtensions { public static IServiceCollection AddPasswordStrengthPolicy(this IServiceCollection services) { - services.TryAddScoped(); + services.TryAddScoped(); services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddScoped(); + services.TryAddSingleton(); + services.TryAddScoped(); return services; } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/DefaultPasswordPolicyContextProvider.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/UAuthPasswordPolicyContextProvider.cs similarity index 83% rename from src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/DefaultPasswordPolicyContextProvider.cs rename to src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/UAuthPasswordPolicyContextProvider.cs index e41a2fe9..1e2b3a3d 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/DefaultPasswordPolicyContextProvider.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/UAuthPasswordPolicyContextProvider.cs @@ -3,7 +3,7 @@ namespace CodeBeam.UltimateAuth.Credentials.Policies; -public sealed class DefaultPasswordPolicyContextProvider : IPasswordPolicyContextProvider +public sealed class UAuthPasswordPolicyContextProvider : IPasswordPolicyContextProvider { public Task CreateAsync(TenantKey tenant, UserKey userKey, string password, CancellationToken ct = default) { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/DefaultPasswordStrengthPolicy.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/UAuthPasswordStrengthPolicy.cs similarity index 95% rename from src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/DefaultPasswordStrengthPolicy.cs rename to src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/UAuthPasswordStrengthPolicy.cs index 0c5d797c..61b5a7d4 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/DefaultPasswordStrengthPolicy.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/UAuthPasswordStrengthPolicy.cs @@ -3,7 +3,7 @@ namespace CodeBeam.UltimateAuth.Credentials.Policies; -public sealed class DefaultPasswordStrengthPolicy(IPasswordCommonPasswordChecker commonPasswordChecker, IPasswordUserSimilarityChecker userSimilarityChecker) : IPasswordStrengthPolicy +public sealed class UAuthPasswordStrengthPolicy(IPasswordCommonPasswordChecker commonPasswordChecker, IPasswordUserSimilarityChecker userSimilarityChecker) : IPasswordStrengthPolicy { public async Task ValidateAsync(PasswordPolicyContext context, EffectivePasswordPolicyOptions options, CancellationToken ct = default) { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/DefaultPasswordUserSimilarityChecker.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/UAuthPasswordUserSimilarityChecker.cs similarity index 97% rename from src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/DefaultPasswordUserSimilarityChecker.cs rename to src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/UAuthPasswordUserSimilarityChecker.cs index 0b3a9012..e6c35fa1 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/DefaultPasswordUserSimilarityChecker.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/UAuthPasswordUserSimilarityChecker.cs @@ -3,7 +3,7 @@ namespace CodeBeam.UltimateAuth.Credentials.Policies; -public sealed class DefaultPasswordUserSimilarityChecker : IPasswordUserSimilarityChecker +public sealed class UAuthPasswordUserSimilarityChecker : IPasswordUserSimilarityChecker { public bool IsSimilar(PasswordPolicyContext context, PasswordPolicyLevel level) { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/EfCorePasswordHistoryAtomicityTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/EfCorePasswordHistoryAtomicityTests.cs new file mode 100644 index 00000000..9f5a7626 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/EfCorePasswordHistoryAtomicityTests.cs @@ -0,0 +1,177 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Credentials.Options; +using CodeBeam.UltimateAuth.Credentials.Policies; +using CodeBeam.UltimateAuth.Credentials.Reference; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials; + +public sealed class EfCorePasswordHistoryAtomicityTests +{ + private static readonly TenantKey Tenant = TenantKey.Single; + private static readonly DateTimeOffset Now = + new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); + + private static PasswordHash Hash(string value) => + PasswordHash.Create("test", value); + + private static EffectivePasswordPolicyOptions Options(int rememberLast) => + PasswordPolicyOptionsResolver.Resolve( + new PasswordPolicyOptions + { + History = new PasswordHistoryOptions + { + RememberLast = rememberLast + } + }); + + [Theory] + [InlineData("add")] + [InlineData("prune")] + public async Task FailedHistoryOperation_RollsBackCredentialAndHistory( + string failurePoint) + { + await using var connection = + new SqliteConnection("Data Source=:memory:"); + + await connection.OpenAsync(); + + var dbOptions = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + Guid credentialId; + Guid originalHistoryId; + + await using (var db = new UAuthCredentialDbContext(dbOptions)) + { + await db.Database.EnsureCreatedAsync(); + + var user = UserKey.New(); + + var credential = PasswordCredential.Create( + id: Guid.NewGuid(), + tenant: Tenant, + userKey: user, + secretHash: Hash("current"), + security: CredentialSecurityState.Active(), + metadata: new CredentialMetadata(), + now: Now); + + credentialId = credential.Id; + + var credentials = new EfCorePasswordCredentialStore( + db, new TenantExecutionContext(Tenant)); + + var history = new EfCorePasswordHistoryStore( + db, Tenant); + + await credentials.AddAsync(credential); + + originalHistoryId = Guid.NewGuid(); + + await history.AddAsync(new PasswordHistoryEntry + { + Id = originalHistoryId, + Tenant = Tenant, + UserKey = user, + CredentialKey = new CredentialKey(Tenant, credentialId), + SecretHash = Hash("older"), + ReplacedAt = Now.AddDays(-1) + }); + + var pipeline = new Mock(); + pipeline.Setup(x => x.ValidateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(PasswordPolicyResult.Success()); + + var hasher = new Mock(); + hasher.Setup(x => x.Hash(It.IsAny())) + .Returns((string password) => Hash(password)); + + var credentialFactory = new Mock(); + credentialFactory.Setup(x => x.Create(Tenant)) + .Returns(credentials); + + var historyFactory = new Mock(); + + var failingHistory = new Mock(); + + failingHistory.Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .Returns( + (entry, ct) => + failurePoint == "add" + ? Task.FromException( + new InvalidOperationException("Injected add failure")) + : history.AddAsync(entry, ct)); + + failingHistory.Setup(x => x.PruneAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns( + (key, count, ct) => + failurePoint == "prune" + ? Task.FromException( + new InvalidOperationException("Injected prune failure")) + : history.PruneAsync(key, count, ct)); + + historyFactory.Setup(x => x.Create(Tenant)) + .Returns(failingHistory.Object); + + var writer = new PasswordCredentialWriter( + pipeline.Object, + credentialFactory.Object, + historyFactory.Object, + hasher.Object); + + await using var transaction = + await db.Database.BeginTransactionAsync(); + + await Assert.ThrowsAsync(() => + writer.ChangeAsync( + credential, + "new-password", + new PasswordPolicyContext + { + Password = "new-password" + }, + Options(2), + Now.AddMinutes(1), + default)); + + await transaction.RollbackAsync(); + } + + // Verify using a fresh DbContext, not tracked entities. + await using (var verifyDb = new UAuthCredentialDbContext(dbOptions)) + { + var savedCredential = await verifyDb.PasswordCredentials + .AsNoTracking() + .SingleAsync(x => x.Id == credentialId); + + Assert.Equal(Hash("current"), savedCredential.SecretHash); + Assert.Equal(0, savedCredential.Version); + + var history = await verifyDb.PasswordHistory + .AsNoTracking() + .Where(x => x.CredentialId == credentialId) + .ToListAsync(); + + Assert.Single(history); + Assert.Equal(originalHistoryId, history[0].Id); + Assert.Equal(Hash("older"), history[0].SecretHash); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordCredentialWriterTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordCredentialWriterTests.cs new file mode 100644 index 00000000..98f820ae --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordCredentialWriterTests.cs @@ -0,0 +1,317 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; +using CodeBeam.UltimateAuth.Credentials.Policies; +using CodeBeam.UltimateAuth.Credentials.Reference; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials; + +public sealed class PasswordCredentialWriterTests +{ + private static readonly TenantKey Tenant = TenantKey.Single; + private static readonly DateTimeOffset Now = + new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); + + private static PasswordHash Hash(string value) => + PasswordHash.Create("test", value); + + private static EffectivePasswordPolicyOptions Options( + int rememberLast = 0) => + PasswordPolicyOptionsResolver.Resolve( + new PasswordPolicyOptions + { + History = new PasswordHistoryOptions + { + RememberLast = rememberLast + } + }); + + private static PasswordPolicyContext Context() => new() + { + Password = "" + }; + + private static PasswordCredential Credential() + { + return PasswordCredential.Create( + id: Guid.NewGuid(), + tenant: Tenant, + userKey: UserKey.New(), + secretHash: Hash("old"), + security: CredentialSecurityState.Active(), + metadata: new CredentialMetadata(), + now: Now); + } + + private sealed class Fixture + { + public Mock Pipeline { get; } = new(); + public Mock Credentials { get; } = new(); + public Mock CredentialFactory { get; } = new(); + public Mock History { get; } = new(); + public Mock HistoryFactory { get; } = new(); + public Mock Hasher { get; } = new(); + + public PasswordCredentialWriter Writer { get; } + + public Fixture() + { + Pipeline.Setup(x => x.ValidateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(PasswordPolicyResult.Success()); + + CredentialFactory.Setup(x => x.Create(Tenant)) + .Returns(Credentials.Object); + + HistoryFactory.Setup(x => x.Create(Tenant)) + .Returns(History.Object); + + Hasher.Setup(x => x.Hash(It.IsAny())) + .Returns((string password) => Hash(password)); + + Writer = new PasswordCredentialWriter( + Pipeline.Object, + CredentialFactory.Object, + HistoryFactory.Object, + Hasher.Object); + } + } + + [Fact] + public async Task Create_ValidatesAndPersistsHashedPassword() + { + var f = new Fixture(); + var user = UserKey.New(); + + PasswordCredential? saved = null; + + f.Credentials + .Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (credential, _) => saved = credential) + .Returns(Task.CompletedTask); + + var result = await f.Writer.CreateAsync( + Tenant, user, "new-password", + Context(), Options(3), Now, default); + + Assert.Same(result, saved); + Assert.Equal(user, result.UserKey); + Assert.Equal(Hash("new-password"), result.SecretHash); + + f.Pipeline.Verify(x => x.ValidateAsync( + It.Is(r => + r.Context.Password == "new-password" && + r.ExistingCredentialKey == null), + It.IsAny(), + It.IsAny()), Times.Once); + + f.HistoryFactory.Verify( + x => x.Create(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Change_WithHistory_StoresOldHashAndPrunes() + { + var f = new Fixture(); + var credential = Credential(); + var originalHash = credential.SecretHash; + var key = new CredentialKey(Tenant, credential.Id); + + PasswordHistoryEntry? added = null; + var calls = new List(); + + f.Credentials.Setup(x => x.SaveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback(() => calls.Add("save")) + .Returns(Task.CompletedTask); + + f.History.Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (entry, _) => + { + calls.Add("history"); + added = entry; + }) + .Returns(Task.CompletedTask); + + f.History.Setup(x => x.PruneAsync( + key, 3, It.IsAny())) + .Callback(() => calls.Add("prune")) + .Returns(Task.CompletedTask); + + var updated = await f.Writer.ChangeAsync( + credential, "new-password", + Context(), Options(3), + Now.AddMinutes(1), default); + + Assert.Equal(Hash("new-password"), updated.SecretHash); + Assert.NotNull(added); + Assert.NotEqual(Guid.Empty, added.Id); + Assert.Equal(originalHash, added.SecretHash); + Assert.Equal(key, added.CredentialKey); + Assert.Equal(credential.UserKey, added.UserKey); + Assert.Equal(Tenant, added.Tenant); + Assert.Equal(Now.AddMinutes(1), added.ReplacedAt); + + Assert.Equal(new[] { "save", "history", "prune" }, calls); + + f.Credentials.Verify(x => x.SaveAsync( + It.IsAny(), + 0, + It.IsAny()), Times.Once); + + f.Pipeline.Verify(x => x.ValidateAsync( + It.Is(r => + r.Context.Password == "new-password" && + r.ExistingCredentialKey == key), + It.IsAny(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Change_WithoutHistory_PrunesAllButDoesNotAdd() + { + var f = new Fixture(); + var credential = Credential(); + var key = new CredentialKey(Tenant, credential.Id); + + await f.Writer.ChangeAsync( + credential, "new-password", + Context(), Options(0), + Now.AddMinutes(1), default); + + f.History.Verify(x => x.AddAsync( + It.IsAny(), + It.IsAny()), Times.Never); + + f.History.Verify(x => x.PruneAsync( + key, 0, It.IsAny()), Times.Once); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ValidationFailure_PreventsPersistence( + bool change) + { + var f = new Fixture(); + + f.Pipeline.Setup(x => x.ValidateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(PasswordPolicyResult.Failure( + new PasswordPolicyViolation( + PasswordPolicyErrorCodes.ReusedPassword, + "Password was used previously."))); + + var exception = await Assert.ThrowsAsync( + async () => + { + if (change) + { + await f.Writer.ChangeAsync( + Credential(), "reused", + Context(), Options(3), Now, default); + } + else + { + await f.Writer.CreateAsync( + Tenant, UserKey.New(), "reused", + Context(), Options(3), Now, default); + } + }); + + Assert.Equal(PasswordPolicyErrorCodes.ReusedPassword, exception.Code); + + f.Hasher.Verify( + x => x.Hash(It.IsAny()), + Times.Never); + + f.CredentialFactory.Verify( + x => x.Create(It.IsAny()), + Times.Never); + + f.HistoryFactory.Verify( + x => x.Create(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Change_WhenCredentialSaveFails_DoesNotWriteHistory() + { + var f = new Fixture(); + + f.Credentials.Setup(x => x.SaveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("save failed")); + + await Assert.ThrowsAsync(() => + f.Writer.ChangeAsync( + Credential(), "new-password", + Context(), Options(3), Now, default)); + + f.HistoryFactory.Verify( + x => x.Create(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Change_WhenHistoryAddFails_DoesNotPrune() + { + var f = new Fixture(); + + f.History.Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("history failed")); + + await Assert.ThrowsAsync(() => + f.Writer.ChangeAsync( + Credential(), "new-password", + Context(), Options(3), Now, default)); + + f.History.Verify(x => x.PruneAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task Change_WhenPruneFails_PropagatesFailure() + { + var f = new Fixture(); + + f.History.Setup(x => x.PruneAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("prune failed")); + + await Assert.ThrowsAsync(() => + f.Writer.ChangeAsync( + Credential(), "new-password", + Context(), Options(3), Now, default)); + + f.History.Verify(x => x.AddAsync( + It.IsAny(), + It.IsAny()), Times.Once); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordPolicyOptionsTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordPolicyOptionsTests.cs index 8b755fa5..6e561909 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordPolicyOptionsTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordPolicyOptionsTests.cs @@ -123,4 +123,210 @@ public void DisabledIndividualCheck_IsAllowed() Assert.True(result.Succeeded); } + + + [Fact] + public void NullServerOptions_IsRejected() + { + var result = _validator.Validate(null, null!); + + Assert.True(result.Failed); + Assert.Contains( + result.Failures, + x => x.Contains("cannot be null")); + } + + [Fact] + public void NullHistoryOptions_IsRejected() + { + var result = _validator.Validate( + null, + new UAuthServerOptions + { + Password = new PasswordPolicyOptions + { + History = null! + } + }); + + Assert.True(result.Failed); + Assert.Contains( + result.Failures, + x => x.Contains("History")); + } + + [Theory] + [InlineData(-1)] + [InlineData(999)] + public void InvalidCommonPasswordsLevel_IsRejected(int level) + { + var result = _validator.Validate( + null, + new UAuthServerOptions + { + Password = new PasswordPolicyOptions + { + CommonPasswords = (PasswordPolicyLevel)level + } + }); + + Assert.True(result.Failed); + Assert.Contains( + result.Failures, + x => x.Contains("CommonPasswords")); + } + + [Theory] + [InlineData(-1)] + [InlineData(999)] + public void InvalidUserSimilarityLevel_IsRejected(int level) + { + var result = _validator.Validate( + null, + new UAuthServerOptions + { + Password = new PasswordPolicyOptions + { + UserSimilarity = (PasswordPolicyLevel)level + } + }); + + Assert.True(result.Failed); + Assert.Contains( + result.Failures, + x => x.Contains("UserSimilarity")); + } + + [Theory] + [InlineData(PasswordPolicyLevel.Basic)] + [InlineData(PasswordPolicyLevel.Standard)] + [InlineData(PasswordPolicyLevel.Strict)] + public void ValidGlobalLevels_AreAccepted( + PasswordPolicyLevel level) + { + var result = _validator.Validate( + null, + new UAuthServerOptions + { + Password = new PasswordPolicyOptions + { + Level = level + } + }); + + Assert.True(result.Succeeded); + } + + [Theory] + [InlineData(0)] + [InlineData(10)] + public void ValidHistoryBoundaryValues_AreAccepted(int count) + { + var result = _validator.Validate( + null, + new UAuthServerOptions + { + Password = new PasswordPolicyOptions + { + History = new PasswordHistoryOptions + { + RememberLast = count + } + } + }); + + Assert.True(result.Succeeded); + } + + [Theory] + [InlineData(PasswordPolicyLevel.Disabled)] + [InlineData(PasswordPolicyLevel.Basic)] + [InlineData(PasswordPolicyLevel.Standard)] + [InlineData(PasswordPolicyLevel.Strict)] + public void ValidIndividualCheckLevels_AreAccepted( + PasswordPolicyLevel level) + { + var result = _validator.Validate( + null, + new UAuthServerOptions + { + Password = new PasswordPolicyOptions + { + CommonPasswords = level, + UserSimilarity = level + } + }); + + Assert.True(result.Succeeded); + } + + [Fact] + public void ValidLengthBoundaryValues_AreAccepted() + { + var result = _validator.Validate( + null, + new UAuthServerOptions + { + Password = new PasswordPolicyOptions + { + MinLength = PasswordPolicyLimits.MinimumLength, + MaxLength = PasswordPolicyLimits.AbsoluteMaxLength + } + }); + + Assert.True(result.Succeeded); + } + + [Fact] + public void NonPositiveMaxLength_IsRejected() + { + var result = _validator.Validate( + null, + new UAuthServerOptions + { + Password = new PasswordPolicyOptions + { + MaxLength = 0 + } + }); + + Assert.True(result.Failed); + + Assert.Contains( + result.Failures, + x => x.Contains("positive")); + } + + [Fact] + public void MultipleInvalidOptions_ReportAllRelevantFailures() + { + var result = _validator.Validate( + null, + new UAuthServerOptions + { + Password = new PasswordPolicyOptions + { + Level = (PasswordPolicyLevel)999, + CommonPasswords = (PasswordPolicyLevel)999, + UserSimilarity = (PasswordPolicyLevel)999, + History = new PasswordHistoryOptions + { + RememberLast = -1 + } + } + }); + + Assert.True(result.Failed); + + Assert.Equal(4, result.Failures.Count()); + + Assert.Contains(result.Failures, + x => x.Contains("Level")); + Assert.Contains(result.Failures, + x => x.Contains("CommonPasswords")); + Assert.Contains(result.Failures, + x => x.Contains("UserSimilarity")); + Assert.Contains(result.Failures, + x => x.Contains("history count")); + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/EfCorePasswordHistoryStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/EfCorePasswordHistoryStoreContractTests.cs new file mode 100644 index 00000000..5c2744e2 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/EfCorePasswordHistoryStoreContractTests.cs @@ -0,0 +1,55 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Credentials.Reference; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials.Contracts; + +public sealed class EfCorePasswordHistoryStoreContractTests + : PasswordHistoryStoreContractTests +{ + protected override async Task + CreateDatabaseAsync() + { + var database = new Database(); + await database.InitializeAsync(); + return database; + } + + private sealed class Database + : IPasswordHistoryStoreTestDatabase + { + private readonly SqliteConnection _connection; + private readonly UAuthCredentialDbContext _db; + + public Database() + { + _connection = new SqliteConnection( + "Data Source=:memory:"); + + var options = + new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _db = new UAuthCredentialDbContext(options); + } + + public async Task InitializeAsync() + { + await _connection.OpenAsync(); + await _db.Database.EnsureCreatedAsync(); + } + + public IPasswordHistoryStore CreateStore(TenantKey tenant) + => new EfCorePasswordHistoryStore( + _db, tenant); + + public async ValueTask DisposeAsync() + { + await _db.DisposeAsync(); + await _connection.DisposeAsync(); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/IPasswordHistoryStoreTestDatabase.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/IPasswordHistoryStoreTestDatabase.cs new file mode 100644 index 00000000..5aebec66 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/IPasswordHistoryStoreTestDatabase.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Reference; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials.Contracts; + +public interface IPasswordHistoryStoreTestDatabase : IAsyncDisposable +{ + IPasswordHistoryStore CreateStore(TenantKey tenant); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/InMemoryPasswordHistoryStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/InMemoryPasswordHistoryStoreContractTests.cs new file mode 100644 index 00000000..eec7fb1b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/InMemoryPasswordHistoryStoreContractTests.cs @@ -0,0 +1,35 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.InMemory; +using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.InMemory; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials.Contracts; + +public sealed class InMemoryPasswordHistoryStoreContractTests : PasswordHistoryStoreContractTests +{ + protected override Task CreateDatabaseAsync() + { + return Task.FromResult(new Database()); + } + + private sealed class Database : IPasswordHistoryStoreTestDatabase + { + private readonly InMemoryAtomicContextAccessor + _atomicContext = new(); + + private readonly InMemoryPasswordHistoryStoreFactory + _factory; + + public Database() + { + _factory = new InMemoryPasswordHistoryStoreFactory( + _atomicContext); + } + + public IPasswordHistoryStore CreateStore(TenantKey tenant) + => _factory.Create(tenant); + + public ValueTask DisposeAsync() + => ValueTask.CompletedTask; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/PasswordHistoryStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/PasswordHistoryStoreContractTests.cs new file mode 100644 index 00000000..a42a4918 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/PasswordHistoryStoreContractTests.cs @@ -0,0 +1,540 @@ +using AwesomeAssertions; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials.Contracts; + +public abstract class PasswordHistoryStoreContractTests +{ + protected abstract Task CreateDatabaseAsync(); + + private static readonly TenantKey TenantA = + TenantKey.FromExternal("tenant-a"); + + private static readonly TenantKey TenantB = + TenantKey.FromExternal("tenant-b"); + + private static readonly DateTimeOffset Now = + new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); + + private static CredentialKey Key(TenantKey tenant) => + new(tenant, Guid.NewGuid()); + + private static PasswordHistoryEntry Entry( + CredentialKey key, + UserKey user, + int minutes = 0, + Guid? id = null) => new() + { + Id = id ?? Guid.NewGuid(), + Tenant = key.Tenant, + UserKey = user, + CredentialKey = key, + SecretHash = PasswordHash.Create( + "test", $"hash-{Guid.NewGuid():N}"), + ReplacedAt = Now.AddMinutes(minutes) + }; + + [Fact] + public async Task AddAsync_PersistsAllFields() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + var key = Key(TenantA); + var entry = Entry(key, UserKey.New()); + + await store.AddAsync(entry); + + var stored = (await store.GetRecentAsync(key, 10)) + .Should().ContainSingle().Subject; + + stored.Id.Should().Be(entry.Id); + stored.Tenant.Should().Be(entry.Tenant); + stored.UserKey.Should().Be(entry.UserKey); + stored.CredentialKey.Should().Be(entry.CredentialKey); + stored.SecretHash.Should().Be(entry.SecretHash); + stored.ReplacedAt.Should().Be(entry.ReplacedAt); + } + + [Fact] + public async Task GetRecentAsync_ReturnsNewestFirst() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + var key = Key(TenantA); + var user = UserKey.New(); + + var oldest = Entry(key, user, 1); + var newest = Entry(key, user, 3); + var middle = Entry(key, user, 2); + + await store.AddAsync(newest); + await store.AddAsync(oldest); + await store.AddAsync(middle); + + var result = await store.GetRecentAsync(key, 2); + + result.Select(x => x.Id) + .Should().Equal(newest.Id, middle.Id); + } + + [Fact] + public async Task GetRecentAsync_EqualDates_UsesIdDescending() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + var key = Key(TenantA); + var user = UserKey.New(); + + var low = Entry(key, user, id: Guid.Parse( + "00000000-0000-0000-0000-000000000001")); + var high = Entry(key, user, id: Guid.Parse( + "00000000-0000-0000-0000-000000000002")); + + await store.AddAsync(low); + await store.AddAsync(high); + + var result = await store.GetRecentAsync(key, 2); + + result.Select(x => x.Id) + .Should().Equal(high.Id, low.Id); + } + + [Fact] + public async Task GetRecentAsync_Zero_ReturnsEmpty() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + var key = Key(TenantA); + + await store.AddAsync(Entry(key, UserKey.New())); + + (await store.GetRecentAsync(key, 0)) + .Should().BeEmpty(); + } + + [Fact] + public async Task GetRecentAsync_CountGreaterThanAvailable_ReturnsAll() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + var key = Key(TenantA); + + await store.AddAsync(Entry(key, UserKey.New())); + + (await store.GetRecentAsync(key, 100)) + .Should().ContainSingle(); + } + + [Fact] + public async Task GetRecentAsync_NegativeCount_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var act = () => store.GetRecentAsync(Key(TenantA), -1); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task GetRecentAsync_MissingCredential_ReturnsEmpty() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + (await store.GetRecentAsync(Key(TenantA), 5)) + .Should().BeEmpty(); + } + + [Fact] + public async Task GetRecentAsync_DoesNotMixCredentials() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + var keyA = Key(TenantA); + var keyB = Key(TenantA); + var user = UserKey.New(); + + var a = Entry(keyA, user); + await store.AddAsync(a); + await store.AddAsync(Entry(keyB, user)); + + var result = await store.GetRecentAsync(keyA, 10); + + result.Should().ContainSingle(); + result.Single().Id.Should().Be(a.Id); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(10)] + public async Task PruneAsync_KeepsExactlyNewestEntries( + int keepLast) + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + var key = Key(TenantA); + var user = UserKey.New(); + + var entries = Enumerable.Range(0, 5) + .Select(i => Entry(key, user, i)) + .ToArray(); + + foreach (var entry in entries.Reverse()) + await store.AddAsync(entry); + + await store.PruneAsync(key, keepLast); + + var result = await store.GetRecentAsync(key, 20); + var expected = entries + .OrderByDescending(x => x.ReplacedAt) + .Take(keepLast) + .Select(x => x.Id); + + result.Select(x => x.Id).Should().Equal(expected); + } + + [Fact] + public async Task PruneAsync_DoesNotAffectOtherCredentials() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + var keyA = Key(TenantA); + var keyB = Key(TenantA); + var user = UserKey.New(); + + await store.AddAsync(Entry(keyA, user)); + var other = Entry(keyB, user); + await store.AddAsync(other); + + await store.PruneAsync(keyA, 0); + + (await store.GetRecentAsync(keyA, 10)).Should().BeEmpty(); + (await store.GetRecentAsync(keyB, 10)) + .Should().ContainSingle(x => x.Id == other.Id); + } + + [Fact] + public async Task PruneAsync_NegativeCount_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var act = () => store.PruneAsync(Key(TenantA), -1); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task PruneAsync_IsIdempotent() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + var key = Key(TenantA); + var user = UserKey.New(); + + await store.AddAsync(Entry(key, user, 1)); + await store.AddAsync(Entry(key, user, 2)); + + await store.PruneAsync(key, 1); + var first = await store.GetRecentAsync(key, 10); + + await store.PruneAsync(key, 1); + var second = await store.GetRecentAsync(key, 10); + + second.Should().BeEquivalentTo(first); + } + + [Fact] + public async Task DeleteByCredentialAsync_RemovesOnlyTarget() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + var keyA = Key(TenantA); + var keyB = Key(TenantA); + var user = UserKey.New(); + + await store.AddAsync(Entry(keyA, user)); + await store.AddAsync(Entry(keyA, user)); + var other = Entry(keyB, user); + await store.AddAsync(other); + + await store.DeleteByCredentialAsync(keyA); + + (await store.GetRecentAsync(keyA, 10)).Should().BeEmpty(); + (await store.GetRecentAsync(keyB, 10)) + .Should().ContainSingle(x => x.Id == other.Id); + } + + [Fact] + public async Task DeleteByCredentialAsync_Missing_IsIdempotent() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var key = Key(TenantA); + + await store.DeleteByCredentialAsync(key); + await store.DeleteByCredentialAsync(key); + + (await store.GetRecentAsync(key, 10)).Should().BeEmpty(); + } + + [Fact] + public async Task DeleteByUserAsync_RemovesAllUsersCredentials() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + var userA = UserKey.New(); + var userB = UserKey.New(); + var keyA1 = Key(TenantA); + var keyA2 = Key(TenantA); + var keyB = Key(TenantA); + + await store.AddAsync(Entry(keyA1, userA)); + await store.AddAsync(Entry(keyA2, userA)); + var other = Entry(keyB, userB); + await store.AddAsync(other); + + await store.DeleteByUserAsync(userA); + + (await store.GetRecentAsync(keyA1, 10)).Should().BeEmpty(); + (await store.GetRecentAsync(keyA2, 10)).Should().BeEmpty(); + (await store.GetRecentAsync(keyB, 10)) + .Should().ContainSingle(x => x.Id == other.Id); + } + + [Fact] + public async Task DeleteByUserAsync_Missing_IsIdempotent() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + + await store.DeleteByUserAsync(user); + await store.DeleteByUserAsync(user); + } + + [Fact] + public async Task TenantIsolation_GetRecentAndPrune() + { + await using var db = await CreateDatabaseAsync(); + var storeA = db.CreateStore(TenantA); + var storeB = db.CreateStore(TenantB); + var user = UserKey.New(); + var credentialId = Guid.NewGuid(); + + var keyA = new CredentialKey(TenantA, credentialId); + var keyB = new CredentialKey(TenantB, credentialId); + + await storeA.AddAsync(Entry(keyA, user)); + await storeB.AddAsync(Entry(keyB, user)); + + (await storeA.GetRecentAsync(keyA, 10)) + .Should().ContainSingle(); + (await storeB.GetRecentAsync(keyB, 10)) + .Should().ContainSingle(); + + await storeA.PruneAsync(keyA, 0); + + (await storeA.GetRecentAsync(keyA, 10)).Should().BeEmpty(); + (await storeB.GetRecentAsync(keyB, 10)) + .Should().ContainSingle(); + } + + [Fact] + public async Task TenantIsolation_DeleteByUser() + { + await using var db = await CreateDatabaseAsync(); + var storeA = db.CreateStore(TenantA); + var storeB = db.CreateStore(TenantB); + var user = UserKey.New(); + var credentialId = Guid.NewGuid(); + + var keyA = new CredentialKey(TenantA, credentialId); + var keyB = new CredentialKey(TenantB, credentialId); + + await storeA.AddAsync(Entry(keyA, user)); + await storeB.AddAsync(Entry(keyB, user)); + + await storeA.DeleteByUserAsync(user); + + (await storeA.GetRecentAsync(keyA, 10)).Should().BeEmpty(); + (await storeB.GetRecentAsync(keyB, 10)) + .Should().ContainSingle(); + } + + [Fact] + public async Task AddAsync_WrongTenant_ThrowsConflict() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var act = () => store.AddAsync( + Entry(Key(TenantB), UserKey.New())); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should().Be("tenant_mismatch"); + } + + [Fact] + public async Task GetRecentAsync_WrongTenant_ThrowsConflict() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var act = () => store.GetRecentAsync(Key(TenantB), 5); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task PruneAsync_WrongTenant_ThrowsConflict() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var act = () => store.PruneAsync(Key(TenantB), 1); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteByCredentialAsync_WrongTenant_ThrowsConflict() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var act = () => store.DeleteByCredentialAsync(Key(TenantB)); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task AddAsync_EmptyId_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var entry = Entry(Key(TenantA), UserKey.New()) + with + { Id = Guid.Empty }; + + var act = () => store.AddAsync(entry); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task AddAsync_NullEntry_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var act = () => store.AddAsync(null!); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task AddAsync_DuplicateId_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + var key = Key(TenantA); + var entry = Entry(key, UserKey.New()); + + await store.AddAsync(entry); + + var act = () => store.AddAsync(entry); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetRecentAsync_Cancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => store.GetRecentAsync( + Key(TenantA), 5, cts.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task AddAsync_Cancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => store.AddAsync( + Entry(Key(TenantA), UserKey.New()), cts.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task PruneAsync_Cancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => store.PruneAsync( + Key(TenantA), 1, cts.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteByCredentialAsync_Cancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => store.DeleteByCredentialAsync( + Key(TenantA), cts.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteByUserAsync_Cancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => store.DeleteByUserAsync( + UserKey.New(), cts.Token); + + await act.Should().ThrowAsync(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordHistoryValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordHistoryValidatorTests.cs new file mode 100644 index 00000000..a1c1631e --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordHistoryValidatorTests.cs @@ -0,0 +1,331 @@ +using AwesomeAssertions; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials; + +public sealed class UAuthPasswordHistoryValidatorTests +{ + private static readonly TenantKey Tenant = TenantKey.Single; + + private static readonly DateTimeOffset Now = new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); + + private static PasswordHash Hash(string password) => PasswordHash.Create("test", password); + + private static PasswordCredential Credential( + CredentialKey key, + UserKey user, + string password) => + PasswordCredential.Create( + id: key.Id, + tenant: key.Tenant, + userKey: user, + secretHash: Hash(password), + security: CredentialSecurityState.Active(), + metadata: new CredentialMetadata(), + now: Now); + + private static PasswordHistoryEntry History( + CredentialKey key, + UserKey user, + string password, + int minutes) => new() + { + Id = Guid.NewGuid(), + Tenant = key.Tenant, + UserKey = user, + CredentialKey = key, + SecretHash = Hash(password), + ReplacedAt = Now.AddMinutes(minutes) + }; + + private static TestFixture Create( + string currentPassword = "CurrentPassword123!") + { + var key = new CredentialKey(Tenant, Guid.NewGuid()); + var user = UserKey.New(); + + var credentialStore = new Mock(); + var credentialFactory = new Mock(); + var historyStore = new Mock(); + var historyFactory = new Mock(); + var hasher = new Mock(); + + credentialFactory + .Setup(x => x.Create(Tenant)) + .Returns(credentialStore.Object); + + credentialStore + .Setup(x => x.GetAsync( + key, It.IsAny())) + .ReturnsAsync(Credential( + key, user, currentPassword)); + + historyFactory + .Setup(x => x.Create(Tenant)) + .Returns(historyStore.Object); + + hasher + .Setup(x => x.Verify( + It.IsAny(), + It.IsAny())) + .Returns((PasswordHash hash, string password) => + hash == Hash(password)); + + var validator = new UAuthPasswordHistoryValidator( + credentialFactory.Object, + historyFactory.Object, + hasher.Object); + + return new TestFixture( + key, user, validator, + credentialStore, credentialFactory, + historyStore, historyFactory, hasher); + } + + private sealed record TestFixture( + CredentialKey Key, + UserKey User, + UAuthPasswordHistoryValidator Validator, + Mock CredentialStore, + Mock CredentialFactory, + Mock HistoryStore, + Mock HistoryFactory, + Mock Hasher); + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(5)] + [InlineData(10)] + public async Task CurrentPassword_IsAlwaysRejected( + int rememberLast) + { + var f = Create(); + + var result = await f.Validator.ValidateAsync( + f.Key, "CurrentPassword123!", rememberLast); + + result.Succeeded.Should().BeFalse(); + result.Violations.Should().ContainSingle( + x => x.Code == PasswordPolicyErrorCodes.ReusedPassword); + + f.HistoryFactory.Verify( + x => x.Create(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RememberLastZero_AllowsDifferentPassword_WithoutHistoryLookup() + { + var f = Create(); + + var result = await f.Validator.ValidateAsync( + f.Key, "DifferentPassword123!", 0); + + result.Succeeded.Should().BeTrue(); + + f.HistoryFactory.Verify( + x => x.Create(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task HistoricalPassword_IsRejected() + { + var f = Create(); + + f.HistoryStore + .Setup(x => x.GetRecentAsync( + f.Key, 3, It.IsAny())) + .ReturnsAsync(new[] + { + History(f.Key, f.User, "Previous1!", 3), + History(f.Key, f.User, "Previous2!", 2), + History(f.Key, f.User, "Previous3!", 1) + }); + + var result = await f.Validator.ValidateAsync( + f.Key, "Previous2!", 3); + + result.Succeeded.Should().BeFalse(); + result.Violations.Should().ContainSingle( + x => x.Code == PasswordPolicyErrorCodes.ReusedPassword); + } + + [Fact] + public async Task NewPassword_NotPresentInHistory_IsAllowed() + { + var f = Create(); + + f.HistoryStore + .Setup(x => x.GetRecentAsync( + f.Key, 2, It.IsAny())) + .ReturnsAsync(new[] + { + History(f.Key, f.User, "Previous1!", 2), + History(f.Key, f.User, "Previous2!", 1) + }); + + var result = await f.Validator.ValidateAsync( + f.Key, "EntirelyNewPassword123!", 2); + + result.Succeeded.Should().BeTrue(); + } + + [Fact] + public async Task EmptyHistory_AllowsDifferentPassword() + { + var f = Create(); + + f.HistoryStore + .Setup(x => x.GetRecentAsync( + f.Key, 5, It.IsAny())) + .ReturnsAsync(Array.Empty()); + + var result = await f.Validator.ValidateAsync( + f.Key, "NewPassword123!", 5); + + result.Succeeded.Should().BeTrue(); + } + + [Fact] + public async Task HistoryLookup_UsesRequestedLimit() + { + var f = Create(); + + f.HistoryStore + .Setup(x => x.GetRecentAsync( + f.Key, 7, It.IsAny())) + .ReturnsAsync(Array.Empty()); + + await f.Validator.ValidateAsync( + f.Key, "NewPassword123!", 7); + + f.HistoryStore.Verify( + x => x.GetRecentAsync( + f.Key, 7, It.IsAny()), + Times.Once); + } + + [Theory] + [InlineData(-1)] + [InlineData(11)] + [InlineData(int.MinValue)] + [InlineData(int.MaxValue)] + public async Task InvalidRememberLast_ThrowsBeforeStoreAccess( + int rememberLast) + { + var f = Create(); + + var act = () => f.Validator.ValidateAsync( + f.Key, "NewPassword123!", rememberLast); + + await act.Should() + .ThrowAsync(); + + f.CredentialFactory.VerifyNoOtherCalls(); + f.HistoryFactory.VerifyNoOtherCalls(); + } + + [Fact] + public async Task MissingCredential_Throws() + { + var f = Create(); + + f.CredentialStore + .Setup(x => x.GetAsync( + f.Key, It.IsAny())) + .ReturnsAsync((PasswordCredential?)null); + + var act = () => f.Validator.ValidateAsync( + f.Key, "NewPassword123!", 3); + + await act.Should() + .ThrowAsync(); + + f.HistoryFactory.VerifyNoOtherCalls(); + } + + [Fact] + public async Task HistoryVerification_StopsAtFirstMatch() + { + var f = Create(); + + f.HistoryStore + .Setup(x => x.GetRecentAsync( + f.Key, 3, It.IsAny())) + .ReturnsAsync(new[] + { + History(f.Key, f.User, "Match123!", 3), + History(f.Key, f.User, "Older123!", 2), + History(f.Key, f.User, "Oldest123!", 1) + }); + + var result = await f.Validator.ValidateAsync( + f.Key, "Match123!", 3); + + result.Succeeded.Should().BeFalse(); + + // Current credential + first history entry only. + f.Hasher.Verify( + x => x.Verify( + It.IsAny(), + "Match123!"), + Times.Exactly(2)); + } + + [Fact] + public async Task CancelledToken_ThrowsBeforeStoreAccess() + { + var f = Create(); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => f.Validator.ValidateAsync( + f.Key, "NewPassword123!", 3, cts.Token); + + await act.Should() + .ThrowAsync(); + + f.CredentialFactory.VerifyNoOtherCalls(); + f.HistoryFactory.VerifyNoOtherCalls(); + } + + [Fact] + public async Task CancellationToken_IsForwardedToStores() + { + var f = Create(); + + using var cts = new CancellationTokenSource(); + var ct = cts.Token; + + f.CredentialStore + .Setup(x => x.GetAsync(f.Key, ct)) + .ReturnsAsync(Credential( + f.Key, f.User, "CurrentPassword123!")); + + f.HistoryStore + .Setup(x => x.GetRecentAsync(f.Key, 2, ct)) + .ReturnsAsync(Array.Empty()); + + var result = await f.Validator.ValidateAsync( + f.Key, "NewPassword123!", 2, ct); + + result.Succeeded.Should().BeTrue(); + + f.CredentialStore.Verify( + x => x.GetAsync(f.Key, ct), + Times.Once); + + f.HistoryStore.Verify( + x => x.GetRecentAsync(f.Key, 2, ct), + Times.Once); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/DefaultPasswordStrengthPolicyTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordStrengthPolicyTests.cs similarity index 93% rename from tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/DefaultPasswordStrengthPolicyTests.cs rename to tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordStrengthPolicyTests.cs index 131548d9..0657b3ad 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/DefaultPasswordStrengthPolicyTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordStrengthPolicyTests.cs @@ -2,9 +2,9 @@ using CodeBeam.UltimateAuth.Credentials.Options; using CodeBeam.UltimateAuth.Credentials.Policies; -public sealed class DefaultPasswordStrengthPolicyTests +public sealed class UAuthPasswordStrengthPolicyTests { - private readonly IPasswordStrengthPolicy _policy = new DefaultPasswordStrengthPolicy(new BuiltInCommonPasswordChecker(), new DefaultPasswordUserSimilarityChecker()); + private readonly IPasswordStrengthPolicy _policy = new UAuthPasswordStrengthPolicy(new BuiltInCommonPasswordChecker(), new UAuthPasswordUserSimilarityChecker()); private static EffectivePasswordPolicyOptions Resolve(PasswordPolicyLevel level = PasswordPolicyLevel.Standard) => PasswordPolicyOptionsResolver.Resolve(new PasswordPolicyOptions { Level = level }); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordUserSimilarityChecks.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordUserSimilarityChecks.cs new file mode 100644 index 00000000..54ea8477 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordUserSimilarityChecks.cs @@ -0,0 +1,271 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Policies; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials; + +public sealed class UAuthPasswordUserSimilarityCheckerTests +{ + private readonly UAuthPasswordUserSimilarityChecker _checker = new(); + + private static PasswordPolicyContext Context(string password, string? userName = null, + params string[] identifiers) => new() + { + Password = password, + UserName = userName, + UserIdentifiers = identifiers + }; + + // ============================================================ + // BASIC + // ============================================================ + + [Theory] + [InlineData("Alice", "alice")] + [InlineData("ALICE", "alice")] + [InlineData("alice", "ALICE")] + public void Basic_RejectsExactUsernameIgnoringCase(string password, string userName) + { + Assert.True(_checker.IsSimilar(Context(password, userName), PasswordPolicyLevel.Basic)); + } + + [Fact] + public void Basic_RejectsExactIdentifier() + { + Assert.True(_checker.IsSimilar( + Context("alice@example.com", null, "alice@example.com"), + PasswordPolicyLevel.Basic)); + } + + [Fact] + public void Basic_AllowsUsernameEmbeddedInPassword() + { + Assert.False(_checker.IsSimilar( + Context("SecureAlice123!", "alice"), + PasswordPolicyLevel.Basic)); + } + + [Fact] + public void Basic_AllowsDifferentPassword() + { + Assert.False(_checker.IsSimilar( + Context("CompletelyDifferent123!", "alice"), + PasswordPolicyLevel.Basic)); + } + + // ============================================================ + // STANDARD + // ============================================================ + + [Theory] + [InlineData("alice", "SecureAlice123!")] + [InlineData("owner", "Owner-test-password-8!")] + [InlineData("member", "Member-test-password-8!")] + public void Standard_RejectsEmbeddedUsername( + string userName, + string password) + { + Assert.True(_checker.IsSimilar( + Context(password, userName), + PasswordPolicyLevel.Standard)); + } + + [Fact] + public void Standard_RejectsEmbeddedEmailLocalPart() + { + Assert.True(_checker.IsSimilar( + Context("SecureAlice123!", null, "alice@example.com"), + PasswordPolicyLevel.Standard)); + } + + [Fact] + public void Standard_RejectsEmbeddedSecondaryIdentifier() + { + Assert.True(_checker.IsSimilar( + Context( + "SecureMember123!", + "alice", + "alice@example.com", + "member@example.com"), + PasswordPolicyLevel.Standard)); + } + + [Theory] + [InlineData("abc", "SecureAbc123!")] + [InlineData("ab", "SecureAb123!")] + [InlineData("a", "SecureA123!")] + public void Standard_DoesNotRejectShortEmbeddedUsername( + string userName, + string password) + { + Assert.False(_checker.IsSimilar( + Context(password, userName), + PasswordPolicyLevel.Standard)); + } + + [Fact] + public void Standard_RejectsFourCharacterUsername() + { + Assert.True(_checker.IsSimilar( + Context("SecureJohn123!", "john"), + PasswordPolicyLevel.Standard)); + } + + [Fact] + public void Standard_DoesNotApplyStrictNormalization() + { + Assert.False(_checker.IsSimilar( + Context("Secure4l1ce123!", "alice"), + PasswordPolicyLevel.Standard)); + } + + // ============================================================ + // STRICT + // ============================================================ + + [Theory] + [InlineData("alice", "Secure4l1ce123!")] + [InlineData("password", "SecureP@ssw0rd123!")] + [InlineData("master", "SecureM4$ter123!")] + [InlineData("test", "Secure7e$7123!")] + public void Strict_RejectsNormalizedUsername( + string userName, + string password) + { + Assert.True(_checker.IsSimilar( + Context(password, userName), + PasswordPolicyLevel.Strict)); + } + + [Theory] + [InlineData("john.doe", "SecureJohnDoe123!")] + [InlineData("john_doe", "SecureJohnDoe123!")] + [InlineData("john-doe", "SecureJohnDoe123!")] + public void Strict_IgnoresSeparators( + string userName, + string password) + { + Assert.True(_checker.IsSimilar( + Context(password, userName), + PasswordPolicyLevel.Strict)); + } + + [Fact] + public void Strict_NormalizesEmailLocalPart() + { + Assert.True(_checker.IsSimilar( + Context( + "SecureJ0hnD0e123!", + null, + "john.doe@example.com"), + PasswordPolicyLevel.Strict)); + } + + [Fact] + public void Strict_DoesNotMatchUnrelatedPassword() + { + Assert.False(_checker.IsSimilar( + Context("UnrelatedSecure123!", "alice"), + PasswordPolicyLevel.Strict)); + } + + [Fact] + public void Strict_DoesNotMatchShortNormalizedCandidate() + { + Assert.False(_checker.IsSimilar( + Context("Secure4l1123!", "ali"), + PasswordPolicyLevel.Strict)); + } + + // ============================================================ + // INPUT / IDENTIFIERS + // ============================================================ + + [Theory] + [InlineData(PasswordPolicyLevel.Basic)] + [InlineData(PasswordPolicyLevel.Standard)] + [InlineData(PasswordPolicyLevel.Strict)] + public void EmptyPassword_IsNotSimilar( + PasswordPolicyLevel level) + { + Assert.False(_checker.IsSimilar( + Context("", "alice"), + level)); + } + + [Fact] + public void MissingIdentifiers_ReturnsFalse() + { + Assert.False(_checker.IsSimilar( + Context("SecurePassword123!"), + PasswordPolicyLevel.Strict)); + } + + [Fact] + public void WhitespaceIdentifiers_AreIgnored() + { + Assert.False(_checker.IsSimilar( + Context( + "SecurePassword123!", + " ", + " ", + "\t"), + PasswordPolicyLevel.Strict)); + } + + [Fact] + public void Identifiers_AreTrimmed() + { + Assert.True(_checker.IsSimilar( + Context("SecureAlice123!", " alice "), + PasswordPolicyLevel.Standard)); + } + + [Fact] + public void DuplicateIdentifiers_DoNotChangeResult() + { + Assert.True(_checker.IsSimilar( + Context( + "SecureAlice123!", + null, + "alice", + "ALICE", + "alice"), + PasswordPolicyLevel.Standard)); + } + + [Fact] + public void IdentifierWithoutEmailLocalPart_IsHandled() + { + Assert.False(_checker.IsSimilar( + Context("SecureAlice123!", null, "@example.com"), + PasswordPolicyLevel.Standard)); + } + + [Fact] + public void Disabled_AlwaysReturnsFalse() + { + Assert.False(_checker.IsSimilar( + Context("alice", "alice"), + PasswordPolicyLevel.Disabled)); + } + + [Fact] + public void NullContext_Throws() + { + Assert.Throws(() => + _checker.IsSimilar( + null!, + PasswordPolicyLevel.Standard)); + } + + [Theory] + [InlineData(-1)] + [InlineData(999)] + public void InvalidLevel_Throws(int level) + { + Assert.Throws(() => + _checker.IsSimilar( + Context("SecurePassword123!", "alice"), + (PasswordPolicyLevel)level)); + } +} From 913d03c74eda76789947fe7cb30778d8b7bfac68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 2 Nov 2026 11:56:58 +0300 Subject: [PATCH 07/10] Add Client-Side & Client Tests --- .../Custom/LabPasswordRequirements.razor | 98 ++++++++++++++ .../Custom/LabPasswordRequirements.razor.css | 50 ++++++++ .../Pages/Dashboard/UsersPage.razor | 13 +- .../Components/Pages/RegisterPage.razor | 110 ++++++++++++++-- .../Components/_Imports.razor | 1 + .../Contracts/LabPasswordErrorMessages.cs | 42 ++++++ .../Services/LabRegistrationService.cs | 41 +++--- .../Components/Dialogs/CreateUserDialog.razor | 35 +++-- .../Dialogs/CreateUserDialog.razor.cs | 61 ++++++++- .../Contracts/PasswordRequirementContext.cs | 5 + .../Defaults/UAuthActions.cs | 1 + .../IPasswordPolicyEndpointHandler.cs | 8 ++ .../PasswordPolicyEndpointHandler.cs | 21 +++ .../Endpoints/UAuthEndpointRegistrar.cs | 4 + .../Extensions/ServiceCollectionExtensions.cs | 1 + .../RateLimiting/UAuthEndpoint.cs | 1 + .../UAuthEndpointClassification.cs | 1 + .../UAuthPasswordClientValidator.cs | 52 ++++++++ .../Options/UAuthClientEndpointOptions.cs | 6 + .../Abstractions/ICredentialClient.cs | 12 ++ .../Services/UAuthCredentialClient.cs | 7 + .../Dtos}/PasswordPolicyLimits.cs | 2 +- .../Dtos/UAuthPasswordPolicyInfo.cs | 19 +++ .../Options/UAuthPasswordPolicyInfoMapper.cs | 29 +++++ .../PasswordPolicyInfoMappingTests.cs | 69 ++++++++++ .../UAuthPasswordClientValidatorTests.cs | 121 ++++++++++++++++++ 26 files changed, 764 insertions(+), 46 deletions(-) create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/LabPasswordRequirements.razor create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/LabPasswordRequirements.razor.css create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabPasswordErrorMessages.cs create mode 100644 samples/CodeBeam.UltimateAuth.Sample.Shared/Contracts/PasswordRequirementContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPasswordPolicyEndpointHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/PasswordPolicyEndpointHandler.cs create mode 100644 src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthPasswordClientValidator.cs rename src/credentials/{CodeBeam.UltimateAuth.Credentials/Options => CodeBeam.UltimateAuth.Credentials.Contracts/Dtos}/PasswordPolicyLimits.cs (79%) create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/UAuthPasswordPolicyInfo.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Options/UAuthPasswordPolicyInfoMapper.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordPolicyInfoMappingTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordClientValidatorTests.cs diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/LabPasswordRequirements.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/LabPasswordRequirements.razor new file mode 100644 index 00000000..ae39e1b0 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/LabPasswordRequirements.razor @@ -0,0 +1,98 @@ +@using CodeBeam.UltimateAuth.Credentials.Contracts + +@if (Policy is not null) +{ +
+
+ + Password requirements + + + + @Policy.Level + + +
+ +
+ @foreach (var rule in Rules) + { +
+ + + @rule.Label + +
+ } +
+ + @if (Policy.CheckCommonPasswords || + Policy.CheckUserSimilarity || + Policy.CheckPasswordHistory) + { + + Additional security checks are performed when you submit. + + } +
+} + +@code { + [Parameter, EditorRequired] + public UAuthPasswordPolicyInfo? Policy { get; set; } + + [Parameter] + public string? Password { get; set; } + + + private IEnumerable<(string Label, bool Passed)> Rules + { + get + { + if (Policy is null) + yield break; + + var password = Password ?? string.Empty; + var hasPassword = password.Length > 0; + + yield return ( + $"At least {Policy.MinLength} characters", + hasPassword && password.Length >= Policy.MinLength); + + yield return ( + $"At most {Policy.MaxLength} characters", + hasPassword && password.Length <= Policy.MaxLength); + + if (Policy.RequireLetter) + yield return ( + "Contains a letter", + hasPassword && password.Any(char.IsLetter)); + + if (Policy.RequireDigit) + yield return ( + "Contains a number", + hasPassword && password.Any(char.IsDigit)); + + if (Policy.RequireUppercase) + yield return ( + "Contains an uppercase letter", + hasPassword && password.Any(char.IsUpper)); + + if (Policy.RequireLowercase) + yield return ( + "Contains a lowercase letter", + hasPassword && password.Any(char.IsLower)); + + if (Policy.RequireSpecialCharacter) + yield return ( + "Contains a special character", + hasPassword && password.Any(c => !char.IsLetterOrDigit(c))); + } + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/LabPasswordRequirements.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/LabPasswordRequirements.razor.css new file mode 100644 index 00000000..a6af4c2e --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/LabPasswordRequirements.razor.css @@ -0,0 +1,50 @@ +.lab-password-policy { + padding: 14px 16px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 12px; + background: var(--mud-palette-background-gray); +} + +.lab-password-policy-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + font-size: 13px; + font-weight: 700; + color: var(--mud-palette-text-primary); +} + + .lab-password-policy-header .mud-chip { + margin-left: auto; + } + +.lab-password-policy-rules { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 9px 12px; + margin-bottom: 10px; +} + +.lab-password-policy-rule { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + font-size: 12px; + color: var(--mud-palette-text-secondary); +} + + .lab-password-policy-rule .mud-icon-root { + flex-shrink: 0; + } + +.rule-passed { + color: var(--mud-palette-success); +} + +@media (max-width: 480px) { + .lab-password-policy-rules { + grid-template-columns: 1fr; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/UsersPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/UsersPage.razor index 5c2cecf4..180b806a 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/UsersPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/UsersPage.razor @@ -317,7 +317,18 @@ var parameters = new DialogParameters { { x => x.PreviewMode, false }, - { x => x.OnCreateUserAsync, CreateSandboxUserAsync } + { x => x.OnCreateUserAsync, CreateSandboxUserAsync }, + { + x => x.PasswordRequirementsContent, + (RenderFragment)(context => + builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "Policy", context.Policy); + builder.AddAttribute(2, "Password", context.Password); + builder.CloseComponent(); + }) + } }; var dialog = await DialogService.ShowAsync( diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor index 35140163..d978ccf5 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor @@ -1,9 +1,12 @@ @page "/register" +@using CodeBeam.UltimateAuth.Core.Errors + @layout PublicLayout @implements IDisposable @inject LabRegistrationService RegistrationService @inject LabRegistrationAvailabilityService AvailabilityService +@inject IUAuthClient UAuthClient @inject ISnackbar Snackbar @inject NavigationManager Navigation @@ -122,6 +125,23 @@ autocomplete="new-password" Validation="@(new Func(ValidatePassword))" Disabled="_isSubmitting" /> + + @if (_passwordPolicy is not null) + { + + } + else if (_policyLoading) + { + + Loading password requirements... + + } + else + { + + Password requirements will be verified by the server. + + } PasswordPolicyLimits.AbsoluteMaxLength) + return "Password exceeds the maximum allowed length."; + + if (_passwordPolicy is null) + return null; + + var violations = UAuthPasswordClientValidator.Validate(value, _passwordPolicy); + + if (violations.Count == 0) + return null; + + return violations[0] switch + { + PasswordPolicyErrorCodes.TooShort => + $"Password must contain at least {_passwordPolicy.MinLength} characters.", + + PasswordPolicyErrorCodes.TooLong => + $"Password cannot exceed {_passwordPolicy.MaxLength} characters.", + + PasswordPolicyErrorCodes.LetterRequired => + "Password must contain a letter.", + + PasswordPolicyErrorCodes.DigitRequired => + "Password must contain a number.", + + PasswordPolicyErrorCodes.UppercaseRequired => + "Password must contain an uppercase letter.", + + PasswordPolicyErrorCodes.LowercaseRequired => + "Password must contain a lowercase letter.", - return null; + PasswordPolicyErrorCodes.SpecialCharacterRequired => + "Password must contain a special character.", + + PasswordPolicyErrorCodes.InputTooLong => + "Password exceeds the maximum allowed length.", + + _ => "Password does not meet the requirements." + }; } private string? ValidateConfirmPassword(string value) @@ -436,10 +514,7 @@ return Task.CompletedTask; } - private async Task CheckUsernameAsync( - string value, - int version, - CancellationTokenSource cts) + private async Task CheckUsernameAsync(string value, int version, CancellationTokenSource cts) { var token = cts.Token; @@ -495,10 +570,7 @@ } } - private async Task CheckEmailAsync( - string value, - int version, - CancellationTokenSource cts) + private async Task CheckEmailAsync(string value, int version, CancellationTokenSource cts) { var token = cts.Token; @@ -567,6 +639,14 @@ if (!_form.IsValid) return; + var passwordError = ValidatePassword(_password); + + if (passwordError is not null) + { + _errorMessage = passwordError; + return; + } + if (_birthDate is { } birthDate && birthDate.Date > DateTime.Today) { @@ -630,6 +710,10 @@ _registered = true; } + catch (UAuthValidationException ex) + { + _errorMessage = LabPasswordErrorMessages.Get(ex.Code); + } catch (Exception) { _errorMessage = "An unexpected error occurred. If you already submitted your registration, try signing in before registering again."; diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor index 38cdf158..ecfb50ae 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor @@ -25,6 +25,7 @@ @using CodeBeam.UltimateAuth.Client.Blazor @using CodeBeam.UltimateAuth.Client.Diagnostics @using CodeBeam.UltimateAuth.Users.Contracts +@using CodeBeam.UltimateAuth.Credentials.Contracts @using CodeBeam.UltimateAuth.Authorization.Contracts @using CodeBeam.UltimateAuth.Sample.Shared.Common diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabPasswordErrorMessages.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabPasswordErrorMessages.cs new file mode 100644 index 00000000..15f9894a --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabPasswordErrorMessages.cs @@ -0,0 +1,42 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.IdentityLab.Contracts; + +public static class LabPasswordErrorMessages +{ + public static string Get(string? code) => code switch + { + PasswordPolicyErrorCodes.TooShort => + "Your password is too short.", + + PasswordPolicyErrorCodes.TooLong or + PasswordPolicyErrorCodes.InputTooLong => + "Your password exceeds the allowed length.", + + PasswordPolicyErrorCodes.LetterRequired => + "Your password must contain a letter.", + + PasswordPolicyErrorCodes.DigitRequired => + "Your password must contain a number.", + + PasswordPolicyErrorCodes.UppercaseRequired => + "Your password must contain an uppercase letter.", + + PasswordPolicyErrorCodes.LowercaseRequired => + "Your password must contain a lowercase letter.", + + PasswordPolicyErrorCodes.SpecialCharacterRequired => + "Your password must contain a special character.", + + PasswordPolicyErrorCodes.CommonPassword => + "This password is too common. Please choose a less predictable password.", + + PasswordPolicyErrorCodes.SimilarToUser => + "Your password is too similar to your username or email address.", + + PasswordPolicyErrorCodes.ReusedPassword => + "You cannot reuse a previous password.", + + _ => "Your password does not meet the security requirements." + }; +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs index 6e7f4994..94af2313 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Authorization; using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.IdentityLab.Authorization; using CodeBeam.UltimateAuth.IdentityLab.Contracts; @@ -27,21 +28,31 @@ public async Task RegisterAsync(LabRegistrationRequest re var trustedContext = context.WithAttribute(LabProvisioningGate.AttributeKey, LabProvisioningGate.Token); - var createResult = await userApplicationService.CreateUserAsync(trustedContext, - new CreateUserRequest - { - UserName = request.Username.Trim(), - Email = request.Email.Trim(), - Password = request.Password, - FirstName = string.IsNullOrWhiteSpace(request.FirstName) - ? null - : request.FirstName.Trim(), - LastName = string.IsNullOrWhiteSpace(request.LastName) - ? null - : request.LastName.Trim(), - BirthDate = request.BirthDate - }, - ct); + + UserCreateResult? createResult; // Burada mevcut metodun gerçek dönüş tipini kullan. + + try + { + createResult = await userApplicationService.CreateUserAsync( + trustedContext, + new CreateUserRequest + { + UserName = request.Username.Trim(), + Email = request.Email.Trim(), + Password = request.Password, + FirstName = string.IsNullOrWhiteSpace(request.FirstName) + ? null : request.FirstName.Trim(), + LastName = string.IsNullOrWhiteSpace(request.LastName) + ? null : request.LastName.Trim(), + BirthDate = request.BirthDate + }, + ct); + } + catch (UAuthValidationException ex) + { + return LabRegistrationResult.Failed(LabPasswordErrorMessages.Get(ex.Code)); + } + if (!createResult.IsSuccess || string.IsNullOrWhiteSpace(createResult.UserKey)) diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor index d88eb0a0..4ec15298 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor @@ -1,4 +1,5 @@ -@inject IUAuthClient UAuthClient +@using CodeBeam.UltimateAuth.Credentials.Contracts +@inject IUAuthClient UAuthClient @inject ISnackbar Snackbar @implements IDisposable @@ -106,9 +107,29 @@ Margin="Margin.Dense" Required="true" RequiredError="Password is required." + Immediate="true" + MaxLength="@PasswordPolicyLimits.AbsoluteMaxLength" + Validation="@(new Func(ValidatePassword))" autocomplete="new-password" Disabled="@_isSubmitting" /> + @if (_passwordPolicy is not null && PasswordRequirementsContent is not null) + { + @PasswordRequirementsContent(new PasswordRequirementsContext(_passwordPolicy, _password)) + } + else if (_passwordPolicyLoading) + { + + Loading password requirements... + + } + else + { + + Password requirements are verified by UltimateAuth. + + } + - -
- - - Password requirements are enforced by the configured UltimateAuth credential policy. - -
@@ -135,10 +149,7 @@
[Parameter] - public Func>? - OnCreateUserAsync - { get; set; } + public Func>? OnCreateUserAsync { get; set; } + + protected override async Task OnInitializedAsync() + { + try + { + var result = await UAuthClient.Credentials.GetPasswordPolicyAsync(); + + if (result.IsSuccess) + _passwordPolicy = result.Value; + } + catch (Exception) + { + // Server validation remains authoritative. + } + finally + { + _passwordPolicyLoading = false; + } + } private async Task CreateUserAsync() { @@ -46,6 +72,14 @@ private async Task CreateUserAsync() if (!_form.IsValid) return; + var passwordError = ValidatePassword(_password); + + if (passwordError is not null) + { + Snackbar.Add(passwordError, Severity.Error); + return; + } + if (!IdentifiersAvailable) { Snackbar.Add("Please wait until username and email availability checks are complete.", Severity.Warning); @@ -110,6 +144,25 @@ private async Task CreateUserAsync() } } + private string? ValidatePassword(string? value) + { + if (string.IsNullOrEmpty(value)) + return "Password is required."; + + if (value.Length > PasswordPolicyLimits.AbsoluteMaxLength) + return "Password exceeds the maximum allowed length."; + + if (_passwordPolicy is null) + return null; + + var errors = UAuthPasswordClientValidator.Validate( + value, _passwordPolicy); + + return errors.Count == 0 + ? null + : "Password does not meet the configured requirements."; + } + private string? PasswordMatch(string? value) { return _password == value diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Contracts/PasswordRequirementContext.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Contracts/PasswordRequirementContext.cs new file mode 100644 index 00000000..4d1b21a2 --- /dev/null +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Contracts/PasswordRequirementContext.cs @@ -0,0 +1,5 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Sample.Shared.Components.Dialogs; + +public sealed record PasswordRequirementsContext(UAuthPasswordPolicyInfo Policy, string? Password); diff --git a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs index 093ec820..439a3973 100644 --- a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs @@ -116,6 +116,7 @@ public static class Credentials public const string CompleteResetAnonymous = "credentials.completereset.anonymous"; public const string CompleteResetAdmin = "credentials.completereset.admin"; public const string DeleteAdmin = "credentials.delete.admin"; + public const string GetPasswordPolicyAnonymous = "credentials.getpasswordpolicy.anonymous"; } public static class Authorization diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPasswordPolicyEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPasswordPolicyEndpointHandler.cs new file mode 100644 index 00000000..386d96da --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPasswordPolicyEndpointHandler.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface IPasswordPolicyEndpointHandler +{ + IResult GetAsync(HttpContext context); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/PasswordPolicyEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PasswordPolicyEndpointHandler.cs new file mode 100644 index 00000000..e5abda41 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/PasswordPolicyEndpointHandler.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; +using CodeBeam.UltimateAuth.Server.Auth; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +internal sealed class PasswordPolicyEndpointHandler(IAuthFlowContextAccessor authFlow) : IPasswordPolicyEndpointHandler +{ + public IResult GetAsync(HttpContext context) + { + var flow = authFlow.Current; + var effective = PasswordPolicyOptionsResolver.Resolve(flow.Options.Password); + + UAuthPasswordPolicyInfo info = effective.ToInfo(); + + context.Response.Headers.CacheControl = "no-store"; + + return Results.Ok(info); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index 6ba447e7..716c857f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -332,6 +332,10 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options var selfCredentials = self.MapGroup("/credentials"); var adminCredentials = admin.MapGroup("/users/{userKey}/credentials"); + if (Enabled(UAuthActions.Credentials.GetPasswordPolicyAnonymous)) + group.MapPost("/password-policy", ([FromServices] IPasswordPolicyEndpointHandler h, HttpContext context) + => h.GetAsync(context)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)).WithUAuthRateLimit(UAuthEndpoint.PasswordPolicy); + if (Enabled(UAuthActions.Credentials.AddSelf)) selfCredentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) => await h.AddAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)).WithUAuthRateLimit(UAuthEndpoint.CredentialAddSelf); diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index 8dfa422d..11305e4b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -285,6 +285,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); // ASP.NET Core Integration services.AddAuthentication(); diff --git a/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthEndpoint.cs b/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthEndpoint.cs index d2591090..8331894c 100644 --- a/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthEndpoint.cs +++ b/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthEndpoint.cs @@ -82,6 +82,7 @@ public enum UAuthEndpoint SetRolePermissions, DeleteRole, HubEntry, + PasswordPolicy, } internal enum UAuthRateLimitGroup { Sensitive, Standard, Frequent } diff --git a/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthEndpointClassification.cs b/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthEndpointClassification.cs index d2ff588e..6929c13a 100644 --- a/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthEndpointClassification.cs +++ b/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthEndpointClassification.cs @@ -85,6 +85,7 @@ internal static class UAuthEndpointClassification UAuthEndpoint.SetRolePermissions => new(endpoint, UAuthRateLimitGroup.Standard, false), UAuthEndpoint.DeleteRole => new(endpoint, UAuthRateLimitGroup.Standard, false), UAuthEndpoint.HubEntry => new(endpoint, UAuthRateLimitGroup.Sensitive, true), + UAuthEndpoint.PasswordPolicy => new(endpoint, UAuthRateLimitGroup.Standard, true), _ => throw new ArgumentOutOfRangeException(nameof(endpoint)) }; } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthPasswordClientValidator.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthPasswordClientValidator.cs new file mode 100644 index 00000000..13d8a380 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthPasswordClientValidator.cs @@ -0,0 +1,52 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Client; +public static class UAuthPasswordClientValidator +{ + public static IReadOnlyList Validate(string? password, UAuthPasswordPolicyInfo policy) + { + ArgumentNullException.ThrowIfNull(policy); + + var errors = new List(); + + if (string.IsNullOrEmpty(password)) + { + errors.Add(PasswordPolicyErrorCodes.Required); + return errors; + } + + if (password.Length > 256) + { + errors.Add(PasswordPolicyErrorCodes.InputTooLong); + return errors; + } + + if (password.Length < policy.MinLength) + errors.Add(PasswordPolicyErrorCodes.TooShort); + + if (password.Length > policy.MaxLength) + errors.Add(PasswordPolicyErrorCodes.TooLong); + + if (policy.RequireLetter && + !password.Any(char.IsLetter)) + errors.Add(PasswordPolicyErrorCodes.LetterRequired); + + if (policy.RequireDigit && + !password.Any(char.IsDigit)) + errors.Add(PasswordPolicyErrorCodes.DigitRequired); + + if (policy.RequireUppercase && + !password.Any(char.IsUpper)) + errors.Add(PasswordPolicyErrorCodes.UppercaseRequired); + + if (policy.RequireLowercase && + !password.Any(char.IsLower)) + errors.Add(PasswordPolicyErrorCodes.LowercaseRequired); + + if (policy.RequireSpecialCharacter && + !password.Any(c => !char.IsLetterOrDigit(c))) + errors.Add(PasswordPolicyErrorCodes.SpecialCharacterRequired); + + return errors; + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs index 6a8ab4e2..63cffc75 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs @@ -59,4 +59,10 @@ public sealed class UAuthClientEndpointOptions /// Path for the UAuthHub login endpoint (e.g. /uauthhub/entry) /// public string HubLoginPath { get; set; } = "/uauthhub/entry"; + + + /// + /// Endpoint used to retrieve the effective public password policy. + /// + public string PasswordPolicy { get; set; } = "/password-policy"; } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs index ec28848d..4ae92705 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs @@ -112,4 +112,16 @@ public interface ICredentialClient /// This removes softly or permanently the credential from the system. /// Task DeleteUserAsync(UserKey userKey, DeleteCredentialRequest request); + + + /// + /// Retrieves the effective public password policy for the + /// current tenant and client profile. + /// + /// + /// Available without authentication when the server + /// exposes the credentials endpoints. + /// The response contains no password history or user data. + /// + Task> GetPasswordPolicyAsync(CancellationToken ct = default); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs index 264b7f44..7ee1071a 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs @@ -113,4 +113,11 @@ public async Task DeleteUserAsync(UserKey userKey, DeleteCredential var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/credentials/delete"), request); return UAuthResultMapper.From(raw); } + + + public async Task> GetPasswordPolicyAsync(CancellationToken ct = default) + { + var raw = await _request.SendJsonAsync(Url(_options.Endpoints.PasswordPolicy), payload: null, ct: ct); + return UAuthResultMapper.FromJson(raw); + } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyLimits.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyLimits.cs similarity index 79% rename from src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyLimits.cs rename to src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyLimits.cs index ef275566..dee0f9a5 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyLimits.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyLimits.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Credentials.Options; +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public static class PasswordPolicyLimits { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/UAuthPasswordPolicyInfo.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/UAuthPasswordPolicyInfo.cs new file mode 100644 index 00000000..6b2284de --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/UAuthPasswordPolicyInfo.cs @@ -0,0 +1,19 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record UAuthPasswordPolicyInfo +{ + public required PasswordPolicyLevel Level { get; init; } + + public required int MinLength { get; init; } + public required int MaxLength { get; init; } + + public required bool RequireLetter { get; init; } + public required bool RequireDigit { get; init; } + public required bool RequireUppercase { get; init; } + public required bool RequireLowercase { get; init; } + public required bool RequireSpecialCharacter { get; init; } + + public required bool CheckCommonPasswords { get; init; } + public required bool CheckUserSimilarity { get; init; } + public required bool CheckPasswordHistory { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/UAuthPasswordPolicyInfoMapper.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/UAuthPasswordPolicyInfoMapper.cs new file mode 100644 index 00000000..94b80bf4 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/UAuthPasswordPolicyInfoMapper.cs @@ -0,0 +1,29 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Options; + +public static class UAuthPasswordPolicyInfoMapper +{ + public static UAuthPasswordPolicyInfo ToInfo(this EffectivePasswordPolicyOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return new UAuthPasswordPolicyInfo + { + Level = options.Level, + + MinLength = options.MinLength, + MaxLength = options.MaxLength, + + RequireLetter = options.RequireLetter, + RequireDigit = options.RequireDigit, + RequireUppercase = options.RequireUppercase, + RequireLowercase = options.RequireLowercase, + RequireSpecialCharacter = options.RequireSpecialCharacter, + + CheckCommonPasswords = options.CommonPasswords != PasswordPolicyLevel.Disabled, + CheckUserSimilarity = options.UserSimilarity != PasswordPolicyLevel.Disabled, + CheckPasswordHistory = options.RememberLast > 0 + }; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordPolicyInfoMappingTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordPolicyInfoMappingTests.cs new file mode 100644 index 00000000..8dd1214c --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordPolicyInfoMappingTests.cs @@ -0,0 +1,69 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials; + +public sealed class PasswordPolicyInfoMappingTests +{ + [Fact] + public void StandardPolicy_MapsExpectedRequirements() + { + var effective = PasswordPolicyOptionsResolver.Resolve( + new PasswordPolicyOptions()); + + var info = effective.ToInfo(); + + Assert.Equal(PasswordPolicyLevel.Standard, info.Level); + Assert.Equal(8, info.MinLength); + Assert.Equal(128, info.MaxLength); + Assert.True(info.RequireLetter); + Assert.True(info.RequireDigit); + Assert.False(info.RequireUppercase); + Assert.False(info.RequireLowercase); + Assert.False(info.RequireSpecialCharacter); + } + + [Fact] + public void StrictOverrides_ArePreserved() + { + var effective = PasswordPolicyOptionsResolver.Resolve( + new PasswordPolicyOptions + { + Level = PasswordPolicyLevel.Strict, + MinLength = 12, + MaxLength = 64, + RequireDigit = false + }); + + var info = effective.ToInfo(); + + Assert.Equal(PasswordPolicyLevel.Strict, info.Level); + Assert.Equal(12, info.MinLength); + Assert.Equal(64, info.MaxLength); + Assert.False(info.RequireDigit); + Assert.True(info.RequireUppercase); + Assert.True(info.RequireLowercase); + Assert.True(info.RequireSpecialCharacter); + } + + [Fact] + public void OptionalChecks_AreMapped() + { + var effective = PasswordPolicyOptionsResolver.Resolve( + new PasswordPolicyOptions + { + CommonPasswords = PasswordPolicyLevel.Disabled, + UserSimilarity = PasswordPolicyLevel.Strict, + History = new PasswordHistoryOptions + { + RememberLast = 5 + } + }); + + var info = effective.ToInfo(); + + Assert.False(info.CheckCommonPasswords); + Assert.True(info.CheckUserSimilarity); + Assert.True(info.CheckPasswordHistory); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordClientValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordClientValidatorTests.cs new file mode 100644 index 00000000..25429623 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordClientValidatorTests.cs @@ -0,0 +1,121 @@ +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials; + +public sealed class UAuthPasswordClientValidatorTests +{ + [Fact] + public void ValidPassword_ReturnsNoViolations() + { + var errors = UAuthPasswordClientValidator.Validate( + "SecurePass123!", + CreatePolicy()); + + Assert.Empty(errors); + } + + [Theory] + [InlineData("", PasswordPolicyErrorCodes.Required)] + [InlineData("Ab1!", PasswordPolicyErrorCodes.TooShort)] + [InlineData("abcdefgh!", PasswordPolicyErrorCodes.DigitRequired)] + [InlineData("ABCDEFGH1!", PasswordPolicyErrorCodes.LowercaseRequired)] + [InlineData("abcdefgh1!", PasswordPolicyErrorCodes.UppercaseRequired)] + [InlineData("Abcdefgh12", PasswordPolicyErrorCodes.SpecialCharacterRequired)] + public void InvalidPassword_ReturnsExpectedCode(string password, string expectedCode) + { + var errors = UAuthPasswordClientValidator.Validate(password, CreatePolicy()); + + Assert.Contains(expectedCode, errors); + } + + [Fact] + public void TooLongPassword_ReturnsLengthViolation() + { + var errors = UAuthPasswordClientValidator.Validate( + new string('A', 129), + CreatePolicy()); + + Assert.Contains(PasswordPolicyErrorCodes.TooLong, errors); + } + + [Fact] + public void AbsoluteInputLimit_IsEnforced() + { + var errors = UAuthPasswordClientValidator.Validate( + new string('A', 257), + CreatePolicy()); + + Assert.Contains( + PasswordPolicyErrorCodes.InputTooLong, + errors); + } + + [Fact] + public void DisabledCharacterRequirements_AreNotEnforced() + { + var policy = CreatePolicy( + level: PasswordPolicyLevel.Basic, + minLength: 6, + requireLetter: false, + requireDigit: false, + requireUppercase: false, + requireLowercase: false, + requireSpecialCharacter: false); + + var errors = UAuthPasswordClientValidator.Validate( + "abcdefgh", + policy); + + Assert.Empty(errors); + } + + [Fact] + public void ServerOnlyChecks_AreNotEvaluatedLocally() + { + var policy = CreatePolicy( + level: PasswordPolicyLevel.Standard, + requireUppercase: false, + requireLowercase: false, + requireSpecialCharacter: false, + checkCommonPasswords: true, + checkUserSimilarity: true, + checkPasswordHistory: true); + + var errors = UAuthPasswordClientValidator.Validate( + "qwerty123", + policy); + + Assert.Empty(errors); + } + + private static UAuthPasswordPolicyInfo CreatePolicy( + PasswordPolicyLevel level = PasswordPolicyLevel.Strict, + int minLength = 8, + int maxLength = 128, + bool requireLetter = true, + bool requireDigit = true, + bool requireUppercase = true, + bool requireLowercase = true, + bool requireSpecialCharacter = true, + bool checkCommonPasswords = false, + bool checkUserSimilarity = false, + bool checkPasswordHistory = false) + { + return new UAuthPasswordPolicyInfo + { + Level = level, + MinLength = minLength, + MaxLength = maxLength, + RequireLetter = requireLetter, + RequireDigit = requireDigit, + RequireUppercase = requireUppercase, + RequireLowercase = requireLowercase, + RequireSpecialCharacter = requireSpecialCharacter, + CheckCommonPasswords = checkCommonPasswords, + CheckUserSimilarity = checkUserSimilarity, + CheckPasswordHistory = checkPasswordHistory + }; + } + +} From 02db087336ce604d7d47481c2a6c52475d9c1775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 2 Nov 2026 12:48:44 +0300 Subject: [PATCH 08/10] Add Docs & More App and Sample Implementation --- .../Pages/Dashboard/MyIdentityPage.razor | 13 +- docs/content/client-sdk/credentials.md | 44 +++ .../getting-started/real-world-setup.md | 7 + docs/content/plugin-domains/credentials.md | 4 + docs/content/security/password-policy.md | 253 ++++++++++++++++++ .../wwwroot/docs/client-sdk/credentials.json | 14 +- .../wwwroot/docs/docs-index.json | 7 + .../getting-started/real-world-setup.json | 9 +- .../docs/plugin-domains/credentials.json | 4 +- .../docs/security/password-policy.json | 94 +++++++ .../Components/Dialogs/CredentialDialog.razor | 22 +- .../Dialogs/CredentialDialog.razor.cs | 43 +++ .../Program.cs | 1 + .../RateLimitingTests.cs | 1 + .../Server/RateLimitingPartitionTests.cs | 4 +- 15 files changed, 510 insertions(+), 10 deletions(-) create mode 100644 docs/content/security/password-policy.md create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/security/password-policy.json diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyIdentityPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyIdentityPage.razor index 3ee4da90..8c730d92 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyIdentityPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyIdentityPage.razor @@ -586,7 +586,18 @@ "Change Password", new DialogParameters { - { x => x.PreviewMode, false } + { x => x.PreviewMode, false }, + { + x => x.PasswordRequirementsContent, + (RenderFragment)(context => + builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "Policy", context.Policy); + builder.AddAttribute(2, "Password", context.Password); + builder.CloseComponent(); + }) + } }, UAuthDialog.GetDialogOptions(MaxWidth.Small)); } diff --git a/docs/content/client-sdk/credentials.md b/docs/content/client-sdk/credentials.md index 0523b2a4..d97431a1 100644 --- a/docs/content/client-sdk/credentials.md +++ b/docs/content/client-sdk/credentials.md @@ -1068,6 +1068,50 @@ The existence of a `CredentialType` enum member does not establish that its comp Use the currently implemented credential contracts and documented authentication flows. +## Retrieving Password Requirements + +UltimateAuth clients can retrieve the effective password policy configured by the authentication server. + +This allows applications to display the current requirements during registration, user creation, and password changes without hardcoding password rules in the UI. + +```csharp +var result = await UAuthClient.Credentials.GetPasswordPolicyAsync(); + +if (result.IsSuccess && result.Value is { } policy) +{ + // Display the effective requirements. + var minimumLength = policy.MinLength; + var maximumLength = policy.MaxLength; + var level = policy.Level; +} +``` + +The response includes the configured policy level, effective length and character requirements, and indicators for server-side common-password, user-similarity, and password-history checks. + +### Client-Side Validation + +Use `UAuthPasswordClientValidator` to provide immediate feedback before submitting a password: + +```csharp +using CodeBeam.UltimateAuth.Client; + +var errors = UAuthPasswordClientValidator.Validate( + password, + policy); + +if (errors.Count == 0) +{ + // Local character and length requirements are satisfied. + // The server must still validate the submitted password. +} +``` + +The validator returns `PasswordPolicyErrorCodes` values for failed local requirements. + +**Client-side validation is advisory, not authoritative.** The server remains responsible for the complete password-policy decision, including common-password detection, user similarity, and password history. + +For the built-in Basic, Standard, and Strict requirements, configuration examples, and password-history behavior, see [Password Policy & History](/docs/security/password-policy). + ## Next Steps - [Authentication](/docs/client/authentication) — Login, logout, refresh, and authentication flows. diff --git a/docs/content/getting-started/real-world-setup.md b/docs/content/getting-started/real-world-setup.md index aa295625..cf851803 100644 --- a/docs/content/getting-started/real-world-setup.md +++ b/docs/content/getting-started/real-world-setup.md @@ -264,11 +264,18 @@ builder.Services.AddUltimateAuthServer(options => options.Login.LockoutDuration = TimeSpan.FromMinutes(15); options.Session.Lifetime = TimeSpan.FromDays(7); options.Identifiers.Behavior.AllowMultipleUsernames = true; + options.Password.Level = PasswordPolicyLevel.Strict; }); ``` The effective authentication mode is resolved at runtime. The application does not need to select a single mode globally. +### Password Security Configuration + +UltimateAuth uses the Standard password policy by default. Applications can select Basic or Strict, or override individual requirements through `options.Password`. + +For the preset comparison, configuration examples, and password-history settings, see [Password Policy & History](/docs/security/password-policy). + ## 4. UAuthHub — Dedicated Authentication Server **Best for:** Applications where multiple clients share one authentication authority. diff --git a/docs/content/plugin-domains/credentials.md b/docs/content/plugin-domains/credentials.md index 0858f034..a4dfb6c4 100644 --- a/docs/content/plugin-domains/credentials.md +++ b/docs/content/plugin-domains/credentials.md @@ -115,6 +115,10 @@ Secret verification and credential usability are related but distinct decisions. A correctly supplied password must not make a revoked or otherwise unusable credential valid. +Password strength, common-password detection, user similarity, and password reuse are governed by configurable security policies. + +See [Password Policy & History](/docs/security/password-policy) for the built-in policy levels, defaults, and configuration options. + ## Credential Validation UltimateAuth separates credential-specific verification from the broader authentication flow. diff --git a/docs/content/security/password-policy.md b/docs/content/security/password-policy.md new file mode 100644 index 00000000..7f42edc6 --- /dev/null +++ b/docs/content/security/password-policy.md @@ -0,0 +1,253 @@ +--- +title: Password Policy & History +order: 6 +group: security +--- + +# Password Policy & History + +UltimateAuth provides configurable password security policies for applications using its reference password credential implementation. + +Instead of requiring every application to implement password validation independently, UltimateAuth provides three built-in policy levels: **Basic**, **Standard**, and **Strict**. + +Each level defines a starting set of password requirements. Developers can override individual requirements without replacing the entire policy. + +Password validation is enforced on the server. Client applications can retrieve the effective policy to display requirements and provide immediate feedback. + +## Password Policy Levels + +UltimateAuth includes three built-in presets. + +**Standard is the default policy level.** + +| Requirement | Basic | Standard (Default) | Strict | +|---|---|---|---| +| Minimum length | 8 | 8 | 15 | +| Maximum length | 128 | 128 | 128 | +| Require letter | No | Yes | Yes | +| Require digit | No | Yes | Yes | +| Require uppercase | No | No | Yes | +| Require lowercase | No | No | Yes | +| Require special character | No | No | Yes | +| Common-password detection | Basic | Standard | Strict | +| User-similarity detection | Basic | Standard | Strict | +| Previous passwords remembered | 0 | 0 | 0 | + +These values are the built-in defaults, not fixed limits for every application. + +### Basic + +Basic provides a minimum password length without requiring particular character classes. + +It still enables basic common-password and user-similarity checks. + +This preset is intended for applications that prefer fewer composition requirements while retaining server-side checks against predictable passwords. + +### Standard + +Standard is the default UltimateAuth password policy. + +It requires at least eight characters, including a letter and a digit, and enables Standard-level common-password and user-similarity checks. + +Applications do not need to explicitly select Standard unless they want to make their configuration intentional. + +### Strict + +Strict increases the minimum length to 15 characters and requires letters, digits, uppercase letters, lowercase letters, and special characters. + +It also selects Strict-level common-password and user-similarity checks. + +Strict is available for applications that require more restrictive password composition rules. It is not automatically the best policy for every application: usability, password-manager compatibility, account recovery, and the application's threat model should also be considered. + +## Configuring Password Policies + +Password policies are configured through `UAuthServerOptions.Password`. + +### Use a built-in preset + +In `Program.cs`: + +```csharp +using CodeBeam.UltimateAuth.Credentials.Contracts; + +builder.Services.AddUltimateAuthServer(options => +{ + options.Password.Level = PasswordPolicyLevel.Strict; +}); +``` + +This applies the Strict preset, including its default length, character requirements, and optional security checks. + +### Customize individual requirements + +You can override any supported requirement without creating a custom policy implementation. + +```csharp +using CodeBeam.UltimateAuth.Credentials.Contracts; + +builder.Services.AddUltimateAuthServer(options => +{ + options.Password.Level = PasswordPolicyLevel.Standard; + + options.Password.MinLength = 12; + options.Password.MaxLength = 64; + + options.Password.RequireUppercase = true; + options.Password.RequireSpecialCharacter = true; +}); +``` + +The effective policy starts with Standard and applies the explicitly configured overrides. + +Unspecified requirements retain their preset values. + +For example, this configuration still requires a letter and a digit, even though those properties are not explicitly set. + +### Disable an individual security check + +Common-password detection and user-similarity detection are configured independently from the main preset. + +```csharp +builder.Services.AddUltimateAuthServer(options => +{ + options.Password.Level = PasswordPolicyLevel.Standard; + + options.Password.CommonPasswords = + PasswordPolicyLevel.Strict; + + options.Password.UserSimilarity = + PasswordPolicyLevel.Disabled; +}); +``` + +This keeps Standard password composition rules, increases common-password detection to Strict, and disables user-similarity detection. + +Disabling security checks should be a deliberate application decision. + +## Password Length Limits + +UltimateAuth defines the following limits: + +| Setting | Value | +|---|---| +| Minimum configurable password length | 6 | +| Default maximum password length | 128 | +| Absolute input length limit | 256 | + +The built-in presets use a maximum length of 128 characters. + +Applications can customize the configured maximum within the supported limits. The absolute limit is a defensive boundary and is not intended to replace application-level password policy configuration. + +Invalid password policy options are rejected during server options validation. + +## Common-Password Detection + +A password may satisfy every character requirement and still be easy to guess. + +For example, a password such as `qwerty123` may satisfy Standard's letter, digit, and length requirements but still be rejected by the server's common-password checker. + +UltimateAuth supports independently configurable detection levels: + +- `Disabled` +- `Basic` +- `Standard` +- `Strict` + +The selected level determines the checker configuration used by the reference implementation. + +Common-password detection is a server-side operation. A client-side character validator cannot establish whether a password passes this check. + +## User-Similarity Detection + +Passwords that closely resemble account identifiers or other available user information may be predictable. + +UltimateAuth provides user-similarity checking with the same configurable levels: + +- `Disabled` +- `Basic` +- `Standard` +- `Strict` + +The server evaluates similarity using the available password-policy context. + +Client applications should not attempt to reproduce the complete similarity decision from public policy information alone. + +## Password History + +Password history helps prevent users from repeatedly selecting passwords they have used before. + +UltimateAuth can remember a configurable number of previous passwords and reject reuse during applicable password credential operations. + +### Enable password history + +```csharp +builder.Services.AddUltimateAuthServer(options => +{ + options.Password.History.RememberLast = 5; +}); +``` + +`RememberLast` controls how many previous passwords are checked. + +| Value | Behavior | +|---|---| +| `0` | Previous-password history checking is disabled | +| `1–10` | Check up to the configured number of previous passwords | + +The default value is `0`. + +The reference password-history validator also checks whether the submitted password matches the current credential, independently of the configured previous-password count. + +Password history compares password candidates against protected password representations; it does not require retaining previous passwords in plaintext. + +### Persistence + +Password history requires an appropriate history store. + +UltimateAuth provides reference storage implementations for in-memory and Entity Framework Core persistence. + +For persistent deployments, apply the required database migrations before enabling password-history functionality. + +An in-memory store is useful for development and testing but does not provide durable history across application restarts. + +## Server Enforcement and Client Feedback + +UltimateAuth distinguishes between authoritative password validation and client-side guidance. + +**The server is the security boundary.** + +Client applications may retrieve the effective policy and validate its publicly expressible requirements, including: + +- Minimum and maximum length +- Letter and digit requirements +- Uppercase and lowercase requirements +- Special-character requirements + +Other checks remain server-authoritative: + +- Common-password detection +- User-similarity detection +- Password history + +A password passing client-side validation is not guaranteed to be accepted by the server. + +Applications should handle server-side password-policy violations as expected validation errors, rather than displaying them as unexpected application failures. + +For client API usage and examples, see [Client SDK — Credentials](/docs/client/credentials). + +## Security Considerations + +Password policies are one part of credential security, not a replacement for the broader authentication system. + +Applications should also consider secure password hashing, transport security, login throttling, account recovery, session revocation, and appropriate authorization for credential-management operations. + +Changing a password may also affect existing authentication sessions, depending on the configured credential session policy. + +See [Session Security Model](/docs/security/session-security) for the relevant session security boundaries. + +## Next Steps + +- [Credentials Domain](/docs/plugin-domains/credentials) — Understand credential ownership, validation, and lifecycle. +- [Client SDK — Credentials](/docs/client/credentials) — Retrieve password requirements and implement credential-management flows. +- [Real-World Setup](/docs/getting-started/real-world-setup) — Configure UltimateAuth in a real application. +- [Session Security Model](/docs/security/session-security) — Understand the security consequences of credential changes. diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/credentials.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/credentials.json index 1a9590e6..771a8890 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/credentials.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/credentials.json @@ -1,7 +1,7 @@ { "Slug": "client-sdk/credentials", "Title": "Credentials", - "Html": "\n\u003Cp\u003EUltimateAuth provides a dedicated client API for managing authentication credentials, including password creation, password changes, credential revocation, account recovery, and administrative credential operations.\u003C/p\u003E\n\u003Cp\u003EThrough \u003Ccode\u003EUAuthClient.Credentials\u003C/code\u003E, applications can implement credential management without manually constructing requests to the underlying endpoints.\u003C/p\u003E\n\u003Cp\u003ECredential management is security-sensitive. UltimateAuth separates authenticated credential changes from recovery workflows and provides configurable session invalidation policies.\u003C/p\u003E\n\u003Cp\u003EThis guide covers the current \u003Ccode\u003EICredentialClient\u003C/code\u003E API, its request and response models, and practical implementation patterns.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-credentials\u0022\u003E1. Understanding Credentials\u003C/h2\u003E\n\u003Cp\u003EA credential is authentication material associated with a user.\u003C/p\u003E\n\u003Cp\u003EPasswords are the primary credential type supported by the current credential management request contracts.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth also defines a broader \u003Ccode\u003ECredentialType\u003C/code\u003E enum for additional authentication mechanisms.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic enum CredentialType\n{\n Password = 0,\n OneTimeCode = 10,\n EmailOtp = 11,\n SmsOtp = 12,\n Totp = 30,\n Passkey = 100,\n Certificate = 200,\n ApiKey = 210,\n External = 1000\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese enum values do not mean every credential type is currently supported by the credential management API.\u003C/p\u003E\n\u003Cp\u003EFor example, \u003Ccode\u003EAddCredentialRequest\u003C/code\u003E currently accepts a secret but does not expose a credential type selector.\u003C/p\u003E\n\u003Cp\u003EDo not assume that passkeys, API keys, or external authentication credentials can be created through \u003Ccode\u003EAddMyAsync\u003C/code\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022accessing-the-credential-client\u0022\u003E2. Accessing the Credential Client\u003C/h2\u003E\n\u003Cp\u003EInject \u003Ccode\u003EIUAuthClient\u003C/code\u003E into your Blazor component:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@inject IUAuthClient UAuthClient\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ECredential operations are available through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthClient.Credentials\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe \u003Ccode\u003ECredentials\u003C/code\u003E property exposes \u003Ccode\u003EICredentialClient\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EIts operations fall into three groups:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EGroup\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESelf-service\u003C/td\u003E\n\u003Ctd\u003EAdd, change, and revoke the current user\u0027s credentials\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERecovery\u003C/td\u003E\n\u003Ctd\u003EBegin and complete a credential reset\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAdministration\u003C/td\u003E\n\u003Ctd\u003EManage another user\u0027s credentials\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003ERecovery operations are designed to support users who cannot authenticate normally.\u003C/p\u003E\n\u003Cp\u003EAdministrative operations require appropriate server-side authorization.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022change-your-password\u0022\u003E3. Change Your Password\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EChangeMyAsync\u003C/code\u003E when an authenticated user knows their current password and wants to replace it.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.ChangeMyAsync(\n new ChangeCredentialRequest\n {\n CurrentSecret = currentPassword,\n NewSecret = newPassword\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true })\n{\n // Password changed successfully.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;CredentialChangeResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022changecredentialrequest\u0022\u003EChangeCredentialRequest\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EType\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECurrentSecret\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECurrent password; required for self-service changes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENewSecret\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENew password\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ECurrentSecret\u003C/code\u003E is nullable in the shared request contract because administrative changes do not require the user\u0027s existing password.\u003C/p\u003E\n\u003Cp\u003EFor self-service password changes, provide the current secret.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentialchangeresult\u0022\u003ECredentialChangeResult\u003C/h3\u003E\n\u003Cp\u003EThe response contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsSuccess\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the credential change succeeded\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EError\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional operation-level failure\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential type affected by the change\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.ChangeMyAsync(\n new ChangeCredentialRequest\n {\n CurrentSecret = currentPassword,\n NewSecret = newPassword\n });\n\nif (!result.IsSuccess)\n{\n errorMessage = \u0026quot;Unable to change your password.\u0026quot;;\n}\nelse if (result.Value is { IsSuccess: false } change)\n{\n errorMessage = change.Error ?? \u0026quot;Password change failed.\u0026quot;;\n}\nelse\n{\n successMessage = \u0026quot;Your password has been changed.\u0026quot;;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ECheck both the API-level result and the operation-level result when processing credential changes.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-state-event\u0022\u003EClient State Event\u003C/h3\u003E\n\u003Cp\u003EAfter a successful \u003Ccode\u003EChangeMyAsync\u003C/code\u003E operation, the SDK publishes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthStateEvent.CredentialsChangedSelf\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe event uses the configured client state event handling mode.\u003C/p\u003E\n\u003Cp\u003EIt can help the application refresh authentication-related UI.\u003C/p\u003E\n\u003Cp\u003EIt does not replace the server\u0027s credential or session security checks.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022build-a-password-change-form\u0022\u003E4. Build a Password Change Form\u003C/h2\u003E\n\u003Cp\u003EThe following example demonstrates an authenticated password change form in Blazor.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@using CodeBeam.UltimateAuth.Credentials.Contracts\n@inject IUAuthClient UAuthClient\n\n\u0026lt;h3\u0026gt;Change Password\u0026lt;/h3\u0026gt;\n\n\u0026lt;EditForm Model=\u0026quot;_model\u0026quot; OnValidSubmit=\u0026quot;ChangePasswordAsync\u0026quot;\u0026gt;\n \u0026lt;DataAnnotationsValidator /\u0026gt;\n\n \u0026lt;label\u0026gt;Current password\u0026lt;/label\u0026gt;\n \u0026lt;InputText type=\u0026quot;password\u0026quot; @bind-Value=\u0026quot;_model.CurrentPassword\u0026quot; /\u0026gt;\n\n \u0026lt;label\u0026gt;New password\u0026lt;/label\u0026gt;\n \u0026lt;InputText type=\u0026quot;password\u0026quot; @bind-Value=\u0026quot;_model.NewPassword\u0026quot; /\u0026gt;\n\n \u0026lt;button type=\u0026quot;submit\u0026quot; disabled=\u0026quot;@_busy\u0026quot;\u0026gt;Change password\u0026lt;/button\u0026gt;\n\u0026lt;/EditForm\u0026gt;\n\n@if (!string.IsNullOrWhiteSpace(_message))\n{\n \u0026lt;p role=\u0026quot;status\u0026quot;\u0026gt;@_message\u0026lt;/p\u0026gt;\n}\n\n@code {\n private readonly PasswordChangeModel _model = new();\n private bool _busy;\n private string? _message;\n\n private async Task ChangePasswordAsync()\n {\n if (_busy)\n return;\n\n _busy = true;\n _message = null;\n\n try\n {\n var result = await UAuthClient.Credentials.ChangeMyAsync(\n new ChangeCredentialRequest\n {\n CurrentSecret = _model.CurrentPassword,\n NewSecret = _model.NewPassword\n });\n\n if (result.IsSuccess \u0026amp;\u0026amp;\n result.Value is { IsSuccess: true })\n {\n _model.CurrentPassword = \u0026quot;\u0026quot;;\n _model.NewPassword = \u0026quot;\u0026quot;;\n _message = \u0026quot;Password changed successfully.\u0026quot;;\n }\n else\n {\n _message = \u0026quot;Unable to change password.\u0026quot;;\n }\n }\n catch (Exception)\n {\n _message = \u0026quot;The credential service is unavailable.\u0026quot;;\n }\n finally\n {\n _busy = false;\n }\n }\n\n private sealed class PasswordChangeModel\n {\n [System.ComponentModel.DataAnnotations.Required]\n public string CurrentPassword { get; set; } = \u0026quot;\u0026quot;;\n\n [System.ComponentModel.DataAnnotations.Required]\n public string NewPassword { get; set; } = \u0026quot;\u0026quot;;\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis example demonstrates the client integration.\u003C/p\u003E\n\u003Cp\u003EA production form should additionally provide password confirmation, appropriate password policy feedback, and safeguards against repeated submissions.\u003C/p\u003E\n\u003Cp\u003ENever log password values or include them in application telemetry.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022add-a-credential\u0022\u003E5. Add a Credential\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EAddMyAsync\u003C/code\u003E to add a credential to the current user.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.AddMyAsync(\n new AddCredentialRequest\n {\n Secret = password\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true } added)\n{\n Console.WriteLine($\u0026quot;Credential ID: {added.Id}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;CredentialAddResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022addcredentialrequest\u0022\u003EAddCredentialRequest\u003C/h3\u003E\n\u003Cp\u003EThe current request contract is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record AddCredentialRequest\n{\n public required string Secret { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ECredential type and source selection are not currently exposed by this request.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentialaddresult\u0022\u003ECredentialAddResult\u003C/h3\u003E\n\u003Cp\u003EThe response contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsSuccess\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the credential was added\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EError\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional failure information\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECreated credential ID, when available\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECreated credential type, when available\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EAdding a credential is subject to the server\u0027s credential policies.\u003C/p\u003E\n\u003Cp\u003EFor example, the server may reject attempts to create a duplicate or otherwise unsupported credential.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022password-change-versus-password-recovery\u0022\u003E6. Password Change Versus Password Recovery\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth distinguishes between two different workflows.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EWorkflow\u003C/th\u003E\n\u003Cth\u003ETypical situation\u003C/th\u003E\n\u003Cth\u003EAPI\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPassword change\u003C/td\u003E\n\u003Ctd\u003EAuthenticated user knows the current password\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EChangeMyAsync\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPassword recovery\u003C/td\u003E\n\u003Ctd\u003EUser cannot authenticate using the existing password\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EBeginResetAsync\u003C/code\u003E \u2192 \u003Ccode\u003ECompleteResetAsync\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EA password change verifies the existing secret.\u003C/p\u003E\n\u003Cp\u003EPassword recovery uses a separate proof mechanism, such as a reset token or code delivered through a trusted channel.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EDo not use administrative password changes as a replacement for public password recovery.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022begin-password-recovery\u0022\u003E7. Begin Password Recovery\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EBeginResetAsync\u003C/code\u003E to initiate a credential recovery workflow.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.BeginResetAsync(\n new BeginResetCredentialRequest\n {\n Identifier = \u0026quot;john@example.com\u0026quot;,\n CredentialType = CredentialType.Password,\n ResetCodeType = ResetCodeType.Token\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } reset)\n{\n Console.WriteLine($\u0026quot;Expires at: {reset.ExpiresAt}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;CredentialBeginResetResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022beginresetcredentialrequest\u0022\u003EBeginResetCredentialRequest\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifier\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser identifier associated with the recovery request\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential type to recover\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EResetCodeType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReset proof format\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022reset-code-types\u0022\u003EReset Code Types\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic enum ResetCodeType\n{\n Token = 0,\n Code = 10\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse \u003Ccode\u003EToken\u003C/code\u003E for a token-based recovery workflow.\u003C/p\u003E\n\u003Cp\u003EUse \u003Ccode\u003ECode\u003C/code\u003E for a code-based recovery workflow.\u003C/p\u003E\n\u003Cp\u003EThe delivery mechanism is determined by the application\u0027s configured server-side notification implementation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentialbeginresetresult\u0022\u003ECredentialBeginResetResult\u003C/h3\u003E\n\u003Cp\u003EThe result contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record CredentialBeginResetResult\n{\n public DateTimeOffset ExpiresAt { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EThe reset token or code is not returned in the API response.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThis prevents the recovery endpoint from directly exposing the secret needed to take over an account.\u003C/p\u003E\n\u003Cp\u003EThe proof must be delivered through the configured trusted notification channel.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022complete-password-recovery\u0022\u003E8. Complete Password Recovery\u003C/h2\u003E\n\u003Cp\u003EAfter the user receives a reset token or code, call \u003Ccode\u003ECompleteResetAsync\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.CompleteResetAsync(\n new CompleteResetCredentialRequest\n {\n Identifier = \u0026quot;john@example.com\u0026quot;,\n CredentialType = CredentialType.Password,\n ResetToken = resetToken,\n NewSecret = newPassword\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true })\n{\n // Password recovery completed.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;CredentialActionResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022completeresetcredentialrequest\u0022\u003ECompleteResetCredentialRequest\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifier\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier used for the recovery workflow\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential type being recovered\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EResetToken\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDelivered recovery proof\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENewSecret\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReplacement secret\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe property is named \u003Ccode\u003EResetToken\u003C/code\u003E even when the selected reset format is a code.\u003C/p\u003E\n\u003Cp\u003EUse the exact contract property name.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentialactionresult\u0022\u003ECredentialActionResult\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record CredentialActionResult\n{\n public bool IsSuccess { get; init; }\n public string? Error { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (!result.IsSuccess)\n{\n errorMessage = \u0026quot;Unable to complete password recovery.\u0026quot;;\n}\nelse if (result.Value is { IsSuccess: false } action)\n{\n errorMessage = action.Error ?? \u0026quot;Password recovery failed.\u0026quot;;\n}\nelse\n{\n successMessage = \u0026quot;Your password has been reset.\u0026quot;;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAfter a successful \u003Ccode\u003ECompleteResetAsync\u003C/code\u003E operation, the SDK publishes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthStateEvent.CredentialsChanged\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe event is not the same as \u003Ccode\u003ECredentialsChangedSelf\u003C/code\u003E, which is published by \u003Ccode\u003EChangeMyAsync\u003C/code\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022complete-recovery-using-a-numeric-code\u0022\u003E9. Complete Recovery Using a Numeric Code\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports code-based recovery through \u003Ccode\u003EResetCodeType.Code\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EBegin the recovery:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar begin = await UAuthClient.Credentials.BeginResetAsync(\n new BeginResetCredentialRequest\n {\n Identifier = email,\n CredentialType = CredentialType.Password,\n ResetCodeType = ResetCodeType.Code\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThen submit the code received by the user:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar complete = await UAuthClient.Credentials.CompleteResetAsync(\n new CompleteResetCredentialRequest\n {\n Identifier = email,\n CredentialType = CredentialType.Password,\n ResetToken = enteredCode,\n NewSecret = newPassword\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe same completion API handles token-based and code-based recovery.\u003C/p\u003E\n\u003Cp\u003EThe server is responsible for validating the submitted proof against the active recovery state.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022build-a-forgot-password-form\u0022\u003E10. Build a Forgot Password Form\u003C/h2\u003E\n\u003Cp\u003EA typical forgot-password interface has two stages:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003ERequest a recovery token or code.\u003C/li\u003E\n\u003Cli\u003ESubmit the received proof with a new password.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022step-one-request-recovery\u0022\u003EStep One: Request Recovery\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@using CodeBeam.UltimateAuth.Core.Domain\n@using CodeBeam.UltimateAuth.Credentials.Contracts\n@inject IUAuthClient UAuthClient\n\n\u0026lt;h3\u0026gt;Forgot Password\u0026lt;/h3\u0026gt;\n\n\u0026lt;input @bind=\u0026quot;_email\u0026quot; type=\u0026quot;email\u0026quot; placeholder=\u0026quot;Email address\u0026quot; /\u0026gt;\n\n\u0026lt;button type=\u0026quot;button\u0026quot; disabled=\u0026quot;@_busy\u0026quot; @onclick=\u0026quot;BeginRecoveryAsync\u0026quot;\u0026gt;Send recovery instructions\u0026lt;/button\u0026gt;\n\n@if (_message is not null)\n{\n \u0026lt;p role=\u0026quot;status\u0026quot;\u0026gt;@_message\u0026lt;/p\u0026gt;\n}\n\n@code {\n private string _email = \u0026quot;\u0026quot;;\n private string? _message;\n private bool _busy;\n\n private async Task BeginRecoveryAsync()\n {\n if (_busy || string.IsNullOrWhiteSpace(_email))\n return;\n\n _busy = true;\n\n try\n {\n var result = await UAuthClient.Credentials.BeginResetAsync(\n new BeginResetCredentialRequest\n {\n Identifier = _email,\n CredentialType = CredentialType.Password,\n ResetCodeType = ResetCodeType.Token\n });\n\n _message = result.IsSuccess\n ? \u0026quot;If the account is eligible, recovery instructions will be sent.\u0026quot;\n : \u0026quot;Unable to process the recovery request.\u0026quot;;\n }\n catch (Exception)\n {\n _message = \u0026quot;Recovery is temporarily unavailable.\u0026quot;;\n }\n finally\n {\n _busy = false;\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe confirmation message intentionally avoids revealing whether an account exists.\u003C/p\u003E\n\u003Cp\u003EApplications should avoid introducing account enumeration through UI messages, timing differences, or diagnostic details.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022step-two-complete-recovery\u0022\u003EStep Two: Complete Recovery\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@using CodeBeam.UltimateAuth.Core.Domain\n@using CodeBeam.UltimateAuth.Credentials.Contracts\n@inject IUAuthClient UAuthClient\n\n\u0026lt;h3\u0026gt;Reset Password\u0026lt;/h3\u0026gt;\n\n\u0026lt;input @bind=\u0026quot;_email\u0026quot; placeholder=\u0026quot;Email address\u0026quot; /\u0026gt;\n\u0026lt;input @bind=\u0026quot;_token\u0026quot; placeholder=\u0026quot;Recovery token or code\u0026quot; /\u0026gt;\n\u0026lt;input @bind=\u0026quot;_newPassword\u0026quot; type=\u0026quot;password\u0026quot; placeholder=\u0026quot;New password\u0026quot; /\u0026gt;\n\n\u0026lt;button type=\u0026quot;button\u0026quot; disabled=\u0026quot;@_busy\u0026quot; @onclick=\u0026quot;CompleteRecoveryAsync\u0026quot;\u0026gt;Reset password\u0026lt;/button\u0026gt;\n\n@if (_message is not null)\n{\n \u0026lt;p role=\u0026quot;status\u0026quot;\u0026gt;@_message\u0026lt;/p\u0026gt;\n}\n\n@code {\n private string _email = \u0026quot;\u0026quot;;\n private string _token = \u0026quot;\u0026quot;;\n private string _newPassword = \u0026quot;\u0026quot;;\n\n private string? _message;\n private bool _busy;\n\n private async Task CompleteRecoveryAsync()\n {\n if (_busy)\n return;\n\n _busy = true;\n _message = null;\n\n try\n {\n var result = await UAuthClient.Credentials.CompleteResetAsync(\n new CompleteResetCredentialRequest\n {\n Identifier = _email,\n CredentialType = CredentialType.Password,\n ResetToken = _token,\n NewSecret = _newPassword\n });\n\n if (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true })\n {\n _token = \u0026quot;\u0026quot;;\n _newPassword = \u0026quot;\u0026quot;;\n _message = \u0026quot;Password reset successfully.\u0026quot;;\n }\n else\n {\n _message = \u0026quot;Unable to reset password.\u0026quot;;\n }\n }\n catch (Exception)\n {\n _message = \u0026quot;Recovery is temporarily unavailable.\u0026quot;;\n }\n finally\n {\n _busy = false;\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese examples show the SDK integration.\u003C/p\u003E\n\u003Cp\u003EA production implementation should also validate user input, protect recovery proofs from accidental disclosure, and avoid logging reset tokens or passwords.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-reset-notifications\u0022\u003E11. Configure Reset Notifications\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth uses \u003Ccode\u003EICredentialResetNotifier\u003C/code\u003E to deliver recovery tokens and codes.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic interface ICredentialResetNotifier\n{\n Task NotifyAsync(CredentialResetNotification notification, CancellationToken ct = default);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe notification includes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record CredentialResetNotification(\n TenantKey Tenant,\n UserKey UserKey,\n CredentialType CredentialType,\n ResetCodeType CodeType,\n string Token,\n DateTimeOffset ExpiresAt);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe application must provide a secure delivery implementation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022register-the-notifier\u0022\u003ERegister the Notifier\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddScoped\u0026lt;ICredentialResetNotifier, ApplicationCredentialResetNotifier\u0026gt;();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EApplicationCredentialResetNotifier\u003C/code\u003E is an application-defined implementation.\u003C/p\u003E\n\u003Cp\u003EIt might deliver the proof through email, SMS, or another trusted channel associated with the user\u0027s account.\u003C/p\u003E\n\u003Cp\u003EThe notifier should not expose the proof to an arbitrary address supplied by an unauthenticated caller.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022missing-notifier-configuration\u0022\u003EMissing Notifier Configuration\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth provides a \u003Ccode\u003ENotConfiguredCredentialResetNotifier\u003C/code\u003E fallback.\u003C/p\u003E\n\u003Cp\u003EWhen recovery requires notification but no implementation has been configured, the fallback throws an explanatory exception.\u003C/p\u003E\n\u003Cp\u003EThis prevents a reset flow from silently succeeding without a configured proof delivery mechanism.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ENever return recovery tokens to anonymous clients as a substitute for configuring the notifier.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022recovery-security-behavior\u0022\u003E12. Recovery Security Behavior\u003C/h2\u003E\n\u003Cp\u003ECredential recovery is designed around several security properties.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022account-enumeration-resistance\u0022\u003EAccount Enumeration Resistance\u003C/h3\u003E\n\u003Cp\u003ERecovery initiation should not reveal whether a submitted identifier belongs to an account.\u003C/p\u003E\n\u003Cp\u003EApplications should preserve this behavior in their own UI and logging policies.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022proof-expiration\u0022\u003EProof Expiration\u003C/h3\u003E\n\u003Cp\u003ERecovery proofs have a server-controlled lifetime.\u003C/p\u003E\n\u003Cp\u003EThe client does not choose an arbitrary expiration time.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022single-use-proofs\u0022\u003ESingle-Use Proofs\u003C/h3\u003E\n\u003Cp\u003EA successfully consumed recovery proof must not be reusable.\u003C/p\u003E\n\u003Cp\u003EAfter successful completion, a second attempt using the same proof should be rejected.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-and-target-binding\u0022\u003EIdentifier and Target Binding\u003C/h3\u003E\n\u003Cp\u003ERecovery must apply to the correct user and credential.\u003C/p\u003E\n\u003Cp\u003EAdministrative recovery must not permit an identifier belonging to one user to reset another user\u0027s credential.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-revocation\u0022\u003ESession Revocation\u003C/h3\u003E\n\u003Cp\u003ESuccessful recovery may invalidate existing sessions according to the configured credential session policy.\u003C/p\u003E\n\u003Cp\u003EBy default, UltimateAuth revokes all session chains after recovery.\u003C/p\u003E\n\u003Cp\u003EFor further details, see \u003Ca href=\u0022/docs/security/session-security\u0022\u003ESession Security Model\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-your-credential\u0022\u003E13. Revoke Your Credential\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERevokeMyAsync\u003C/code\u003E to revoke a credential belonging to the authenticated user.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.RevokeMyAsync(\n new RevokeCredentialRequest\n {\n Id = credentialId\n });\n\nif (result.IsSuccess)\n{\n // Credential revoked.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe request contract is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record RevokeCredentialRequest\n{\n public Guid Id { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse the credential record ID, not a user ID or session ID.\u003C/p\u003E\n\u003Cp\u003ECredential revocation is distinct from credential deletion.\u003C/p\u003E\n\u003Cp\u003ERevocation changes the credential\u0027s usability, while deletion uses a separate lifecycle operation available through the administrative API.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022state-event\u0022\u003EState Event\u003C/h3\u003E\n\u003Cp\u003EAfter successful \u003Ccode\u003ERevokeMyAsync\u003C/code\u003E, the SDK publishes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthStateEvent.CredentialsChanged\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe application can use this event to refresh credential-related UI.\u003C/p\u003E\n\u003Cp\u003ECredential revocation may affect authentication eligibility and session continuation depending on server policy.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-credential-management\u0022\u003E14. Administrative Credential Management\u003C/h2\u003E\n\u003Cp\u003EAdministrative credential operations target a specific user.\u003C/p\u003E\n\u003Cp\u003EThey require appropriate server-side authorization.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022add-a-credential-1\u0022\u003EAdd a Credential\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.AddUserAsync(userKey,\n new AddCredentialRequest\n {\n Secret = temporaryPassword\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true } added)\n{\n Console.WriteLine(added.Id);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server validates whether the administrative actor may add a credential for the target user.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022change-a-users-password\u0022\u003EChange a User\u0027s Password\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.ChangeUserAsync(userKey,\n new ChangeCredentialRequest\n {\n NewSecret = newPassword\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true })\n{\n // Administrative password change succeeded.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ECurrentSecret\u003C/code\u003E is not required for an authorized administrative credential change.\u003C/p\u003E\n\u003Cp\u003EThis makes administrative credential changes a privileged operation.\u003C/p\u003E\n\u003Cp\u003EDo not expose this capability through ordinary self-service UI.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-a-users-credential\u0022\u003ERevoke a User\u0027s Credential\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.RevokeUserAsync(userKey,\n new RevokeCredentialRequest\n {\n Id = credentialId\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022begin-administrative-recovery\u0022\u003EBegin Administrative Recovery\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.BeginResetUserAsync(userKey,\n new BeginResetCredentialRequest\n {\n Identifier = userEmail,\n CredentialType = CredentialType.Password,\n ResetCodeType = ResetCodeType.Token\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022complete-administrative-recovery\u0022\u003EComplete Administrative Recovery\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.CompleteResetUserAsync(userKey,\n new CompleteResetCredentialRequest\n {\n Identifier = userEmail,\n CredentialType = CredentialType.Password,\n ResetToken = recoveryToken,\n NewSecret = newPassword\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAdministrative recovery remains a proof-based workflow.\u003C/p\u003E\n\u003Cp\u003EThe server validates the relationship between the target user and the supplied identifier.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022delete-a-users-credential\u0022\u003EDelete a User\u0027s Credential\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.DeleteUserAsync(userKey,\n new DeleteCredentialRequest\n {\n Id = credentialId,\n Mode = DeleteMode.Soft\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022hard-delete\u0022\u003EHard Delete\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.DeleteUserAsync(userKey,\n new DeleteCredentialRequest\n {\n Id = credentialId,\n Mode = DeleteMode.Hard\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EHard deletion is a destructive administrative operation.\u003C/p\u003E\n\u003Cp\u003EIts availability and effects depend on server authorization and credential lifecycle policies.\u003C/p\u003E\n\u003Cp\u003EAdministrative operations do not automatically publish the same local credential state events as self-service operations.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-changes-and-session-invalidation\u0022\u003E15. Credential Changes and Session Invalidation\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth allows applications to configure how existing sessions are handled after credential changes and recovery.\u003C/p\u003E\n\u003Cp\u003EThe server exposes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthServerOptions.CredentialSessionPolicy\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe policy contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOption\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnSelfChange\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveCurrent\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESessions after self-service credential changes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnAdminChange\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESessions after administrative credential changes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnRecovery\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESessions after credential recovery\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentialsessionbehavior\u0022\u003ECredentialSessionBehavior\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic enum CredentialSessionBehavior\n{\n PreserveAll = 0,\n PreserveCurrent = 10,\n RevokeAll = 20\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EBehavior\u003C/th\u003E\n\u003Cth\u003EMeaning\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPreserve existing session chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveCurrent\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPreserve the current chain and revoke other chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke all applicable session chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-policy\u0022\u003EConfigure the Policy\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.CredentialSessionPolicy.OnSelfChange = CredentialSessionBehavior.PreserveCurrent;\n options.CredentialSessionPolicy.OnAdminChange = CredentialSessionBehavior.RevokeAll;\n options.CredentialSessionPolicy.OnRecovery = CredentialSessionBehavior.RevokeAll;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe defaults are intended to balance usability and security.\u003C/p\u003E\n\u003Cp\u003EFor example, an authenticated user changing their password can retain their current device while other devices are invalidated.\u003C/p\u003E\n\u003Cp\u003EAn administrator changing a user\u0027s password typically requires broader session revocation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022recovery-and-preservecurrent\u0022\u003ERecovery and PreserveCurrent\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EPreserveCurrent\u003C/code\u003E is not meaningful for many recovery workflows because the user may not have an authenticated current session.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth\u0027s recovery policy documentation specifies that \u003Ccode\u003EPreserveCurrent\u003C/code\u003E is treated as \u003Ccode\u003ERevokeAll\u003C/code\u003E in this context.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-mode-considerations\u0022\u003EAuthentication Mode Considerations\u003C/h3\u003E\n\u003Cp\u003EThe precise effect of session invalidation depends on the authentication mode.\u003C/p\u003E\n\u003Cp\u003EIn particular, a locally validated JWT access token may remain usable until expiration if the resource server does not consult current revocation state.\u003C/p\u003E\n\u003Cp\u003ECredential session policies should be understood together with the application\u0027s token validation strategy.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/security/token-security\u0022\u003EToken Security\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022handling-credential-results\u0022\u003E16. Handling Credential Results\u003C/h2\u003E\n\u003Cp\u003ECredential operations use \u003Ccode\u003EUAuthResult\u003C/code\u003E or \u003Ccode\u003EUAuthResult\u0026lt;T\u0026gt;\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003ESeveral operations also return a domain-level success flag.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-creation\u0022\u003ECredential Creation\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.AddMyAsync(request);\n\nif (!result.IsSuccess)\n{\n // API-level failure.\n}\nelse if (result.Value is { IsSuccess: false } add)\n{\n // Credential creation was rejected.\n Console.WriteLine(add.Error);\n}\nelse if (result.Value is { IsSuccess: true } added)\n{\n Console.WriteLine(added.Id);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-recovery\u0022\u003ECredential Recovery\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.CompleteResetAsync(request);\n\nif (!result.IsSuccess)\n{\n // API-level failure.\n}\nelse if (result.Value is { IsSuccess: false } action)\n{\n // Recovery did not complete successfully.\n}\nelse\n{\n // Recovery succeeded.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022http-status-helpers\u0022\u003EHTTP Status Helpers\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (result.Http.IsUnauthorized)\n{\n // Authentication required.\n}\nelse if (result.Http.IsForbidden)\n{\n // Operation not permitted.\n}\nelse if (result.Http.IsConflict)\n{\n // Conflicting or expired operation state.\n}\nelse if (result.Http.IsBadRequest)\n{\n // Invalid request.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe actual status depends on the server\u0027s response.\u003C/p\u003E\n\u003Cp\u003EAvoid exposing sensitive internal credential errors directly to end users.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-state-events\u0022\u003E17. Credential State Events\u003C/h2\u003E\n\u003Cp\u003EThe current \u003Ccode\u003EUAuthCredentialClient\u003C/code\u003E publishes the following events after successful self-service operations.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003EEvent\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EChangeMyAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialsChangedSelf\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeMyAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialsChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECompleteResetAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialsChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe following operations do not publish those events in the current client implementation:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EAddMyAsync\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EBeginResetAsync\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003EAdministrative credential operations\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EApplications should refresh relevant data explicitly where needed.\u003C/p\u003E\n\u003Cp\u003EEvents help synchronize client state but do not themselves establish that a credential or session has been invalidated on the server.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-considerations\u0022\u003E18. Security Considerations\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022never-expose-stored-secrets\u0022\u003ENever Expose Stored Secrets\u003C/h3\u003E\n\u003Cp\u003ECredential secrets must not be returned through credential management responses.\u003C/p\u003E\n\u003Cp\u003EApplications should never log passwords, reset proofs, or other authentication secrets.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022use-secure-transport\u0022\u003EUse Secure Transport\u003C/h3\u003E\n\u003Cp\u003ECredential operations must be performed over appropriately secured transport.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022protect-recovery-proofs\u0022\u003EProtect Recovery Proofs\u003C/h3\u003E\n\u003Cp\u003ERecovery tokens and codes should be short-lived, single-use, and delivered through trusted channels.\u003C/p\u003E\n\u003Cp\u003EDo not store recovery proofs in persistent client storage unless the application has a carefully justified security requirement.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022keep-self-service-and-administrative-operations-separate\u0022\u003EKeep Self-Service and Administrative Operations Separate\u003C/h3\u003E\n\u003Cp\u003ESelf-service changes require the current credential.\u003C/p\u003E\n\u003Cp\u003EAdministrative changes are privileged and require server-side authorization.\u003C/p\u003E\n\u003Cp\u003EDo not substitute one for the other.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022preserve-account-recovery-safety\u0022\u003EPreserve Account Recovery Safety\u003C/h3\u003E\n\u003Cp\u003EAvoid revealing account existence through public recovery responses.\u003C/p\u003E\n\u003Cp\u003EApply appropriate rate limiting, proof attempt limits, and abuse controls.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022consider-session-consequences\u0022\u003EConsider Session Consequences\u003C/h3\u003E\n\u003Cp\u003EChanging or resetting a password can affect existing sessions.\u003C/p\u003E\n\u003Cp\u003EApplications should explain the expected consequences to users, especially when other devices will be logged out.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022do-not-assume-every-credential-type-is-implemented\u0022\u003EDo Not Assume Every Credential Type Is Implemented\u003C/h3\u003E\n\u003Cp\u003EThe existence of a \u003Ccode\u003ECredentialType\u003C/code\u003E enum member does not establish that its complete lifecycle is supported.\u003C/p\u003E\n\u003Cp\u003EUse the currently implemented credential contracts and documented authentication flows.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/authentication\u0022\u003EAuthentication\u003C/a\u003E \u2014 Login, logout, refresh, and authentication flows.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/identifiers\u0022\u003EIdentifiers\u003C/a\u003E \u2014 Manage user identifiers and ownership verification.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/user-management\u0022\u003EUser Management\u003C/a\u003E \u2014 Manage profiles and user lifecycle.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/session-management\u0022\u003ESession Management\u003C/a\u003E \u2014 Inspect and revoke sessions.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/security/token-security\u0022\u003EToken Security\u003C/a\u003E \u2014 Understand token validation and revocation behavior.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/security/session-security\u0022\u003ESession Security Model\u003C/a\u003E \u2014 Understand session invalidation and account-level security boundaries.\u003C/li\u003E\n\u003C/ul\u003E\n", + "Html": "\n\u003Cp\u003EUltimateAuth provides a dedicated client API for managing authentication credentials, including password creation, password changes, credential revocation, account recovery, and administrative credential operations.\u003C/p\u003E\n\u003Cp\u003EThrough \u003Ccode\u003EUAuthClient.Credentials\u003C/code\u003E, applications can implement credential management without manually constructing requests to the underlying endpoints.\u003C/p\u003E\n\u003Cp\u003ECredential management is security-sensitive. UltimateAuth separates authenticated credential changes from recovery workflows and provides configurable session invalidation policies.\u003C/p\u003E\n\u003Cp\u003EThis guide covers the current \u003Ccode\u003EICredentialClient\u003C/code\u003E API, its request and response models, and practical implementation patterns.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-credentials\u0022\u003E1. Understanding Credentials\u003C/h2\u003E\n\u003Cp\u003EA credential is authentication material associated with a user.\u003C/p\u003E\n\u003Cp\u003EPasswords are the primary credential type supported by the current credential management request contracts.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth also defines a broader \u003Ccode\u003ECredentialType\u003C/code\u003E enum for additional authentication mechanisms.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic enum CredentialType\n{\n Password = 0,\n OneTimeCode = 10,\n EmailOtp = 11,\n SmsOtp = 12,\n Totp = 30,\n Passkey = 100,\n Certificate = 200,\n ApiKey = 210,\n External = 1000\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese enum values do not mean every credential type is currently supported by the credential management API.\u003C/p\u003E\n\u003Cp\u003EFor example, \u003Ccode\u003EAddCredentialRequest\u003C/code\u003E currently accepts a secret but does not expose a credential type selector.\u003C/p\u003E\n\u003Cp\u003EDo not assume that passkeys, API keys, or external authentication credentials can be created through \u003Ccode\u003EAddMyAsync\u003C/code\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022accessing-the-credential-client\u0022\u003E2. Accessing the Credential Client\u003C/h2\u003E\n\u003Cp\u003EInject \u003Ccode\u003EIUAuthClient\u003C/code\u003E into your Blazor component:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@inject IUAuthClient UAuthClient\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ECredential operations are available through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthClient.Credentials\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe \u003Ccode\u003ECredentials\u003C/code\u003E property exposes \u003Ccode\u003EICredentialClient\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EIts operations fall into three groups:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EGroup\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESelf-service\u003C/td\u003E\n\u003Ctd\u003EAdd, change, and revoke the current user\u0027s credentials\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERecovery\u003C/td\u003E\n\u003Ctd\u003EBegin and complete a credential reset\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAdministration\u003C/td\u003E\n\u003Ctd\u003EManage another user\u0027s credentials\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003ERecovery operations are designed to support users who cannot authenticate normally.\u003C/p\u003E\n\u003Cp\u003EAdministrative operations require appropriate server-side authorization.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022change-your-password\u0022\u003E3. Change Your Password\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EChangeMyAsync\u003C/code\u003E when an authenticated user knows their current password and wants to replace it.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.ChangeMyAsync(\n new ChangeCredentialRequest\n {\n CurrentSecret = currentPassword,\n NewSecret = newPassword\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true })\n{\n // Password changed successfully.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;CredentialChangeResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022changecredentialrequest\u0022\u003EChangeCredentialRequest\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EType\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECurrentSecret\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECurrent password; required for self-service changes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENewSecret\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENew password\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ECurrentSecret\u003C/code\u003E is nullable in the shared request contract because administrative changes do not require the user\u0027s existing password.\u003C/p\u003E\n\u003Cp\u003EFor self-service password changes, provide the current secret.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentialchangeresult\u0022\u003ECredentialChangeResult\u003C/h3\u003E\n\u003Cp\u003EThe response contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsSuccess\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the credential change succeeded\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EError\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional operation-level failure\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential type affected by the change\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.ChangeMyAsync(\n new ChangeCredentialRequest\n {\n CurrentSecret = currentPassword,\n NewSecret = newPassword\n });\n\nif (!result.IsSuccess)\n{\n errorMessage = \u0026quot;Unable to change your password.\u0026quot;;\n}\nelse if (result.Value is { IsSuccess: false } change)\n{\n errorMessage = change.Error ?? \u0026quot;Password change failed.\u0026quot;;\n}\nelse\n{\n successMessage = \u0026quot;Your password has been changed.\u0026quot;;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ECheck both the API-level result and the operation-level result when processing credential changes.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-state-event\u0022\u003EClient State Event\u003C/h3\u003E\n\u003Cp\u003EAfter a successful \u003Ccode\u003EChangeMyAsync\u003C/code\u003E operation, the SDK publishes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthStateEvent.CredentialsChangedSelf\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe event uses the configured client state event handling mode.\u003C/p\u003E\n\u003Cp\u003EIt can help the application refresh authentication-related UI.\u003C/p\u003E\n\u003Cp\u003EIt does not replace the server\u0027s credential or session security checks.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022build-a-password-change-form\u0022\u003E4. Build a Password Change Form\u003C/h2\u003E\n\u003Cp\u003EThe following example demonstrates an authenticated password change form in Blazor.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@using CodeBeam.UltimateAuth.Credentials.Contracts\n@inject IUAuthClient UAuthClient\n\n\u0026lt;h3\u0026gt;Change Password\u0026lt;/h3\u0026gt;\n\n\u0026lt;EditForm Model=\u0026quot;_model\u0026quot; OnValidSubmit=\u0026quot;ChangePasswordAsync\u0026quot;\u0026gt;\n \u0026lt;DataAnnotationsValidator /\u0026gt;\n\n \u0026lt;label\u0026gt;Current password\u0026lt;/label\u0026gt;\n \u0026lt;InputText type=\u0026quot;password\u0026quot; @bind-Value=\u0026quot;_model.CurrentPassword\u0026quot; /\u0026gt;\n\n \u0026lt;label\u0026gt;New password\u0026lt;/label\u0026gt;\n \u0026lt;InputText type=\u0026quot;password\u0026quot; @bind-Value=\u0026quot;_model.NewPassword\u0026quot; /\u0026gt;\n\n \u0026lt;button type=\u0026quot;submit\u0026quot; disabled=\u0026quot;@_busy\u0026quot;\u0026gt;Change password\u0026lt;/button\u0026gt;\n\u0026lt;/EditForm\u0026gt;\n\n@if (!string.IsNullOrWhiteSpace(_message))\n{\n \u0026lt;p role=\u0026quot;status\u0026quot;\u0026gt;@_message\u0026lt;/p\u0026gt;\n}\n\n@code {\n private readonly PasswordChangeModel _model = new();\n private bool _busy;\n private string? _message;\n\n private async Task ChangePasswordAsync()\n {\n if (_busy)\n return;\n\n _busy = true;\n _message = null;\n\n try\n {\n var result = await UAuthClient.Credentials.ChangeMyAsync(\n new ChangeCredentialRequest\n {\n CurrentSecret = _model.CurrentPassword,\n NewSecret = _model.NewPassword\n });\n\n if (result.IsSuccess \u0026amp;\u0026amp;\n result.Value is { IsSuccess: true })\n {\n _model.CurrentPassword = \u0026quot;\u0026quot;;\n _model.NewPassword = \u0026quot;\u0026quot;;\n _message = \u0026quot;Password changed successfully.\u0026quot;;\n }\n else\n {\n _message = \u0026quot;Unable to change password.\u0026quot;;\n }\n }\n catch (Exception)\n {\n _message = \u0026quot;The credential service is unavailable.\u0026quot;;\n }\n finally\n {\n _busy = false;\n }\n }\n\n private sealed class PasswordChangeModel\n {\n [System.ComponentModel.DataAnnotations.Required]\n public string CurrentPassword { get; set; } = \u0026quot;\u0026quot;;\n\n [System.ComponentModel.DataAnnotations.Required]\n public string NewPassword { get; set; } = \u0026quot;\u0026quot;;\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis example demonstrates the client integration.\u003C/p\u003E\n\u003Cp\u003EA production form should additionally provide password confirmation, appropriate password policy feedback, and safeguards against repeated submissions.\u003C/p\u003E\n\u003Cp\u003ENever log password values or include them in application telemetry.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022add-a-credential\u0022\u003E5. Add a Credential\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EAddMyAsync\u003C/code\u003E to add a credential to the current user.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.AddMyAsync(\n new AddCredentialRequest\n {\n Secret = password\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true } added)\n{\n Console.WriteLine($\u0026quot;Credential ID: {added.Id}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;CredentialAddResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022addcredentialrequest\u0022\u003EAddCredentialRequest\u003C/h3\u003E\n\u003Cp\u003EThe current request contract is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record AddCredentialRequest\n{\n public required string Secret { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ECredential type and source selection are not currently exposed by this request.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentialaddresult\u0022\u003ECredentialAddResult\u003C/h3\u003E\n\u003Cp\u003EThe response contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsSuccess\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the credential was added\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EError\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional failure information\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECreated credential ID, when available\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECreated credential type, when available\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EAdding a credential is subject to the server\u0027s credential policies.\u003C/p\u003E\n\u003Cp\u003EFor example, the server may reject attempts to create a duplicate or otherwise unsupported credential.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022password-change-versus-password-recovery\u0022\u003E6. Password Change Versus Password Recovery\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth distinguishes between two different workflows.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EWorkflow\u003C/th\u003E\n\u003Cth\u003ETypical situation\u003C/th\u003E\n\u003Cth\u003EAPI\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPassword change\u003C/td\u003E\n\u003Ctd\u003EAuthenticated user knows the current password\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EChangeMyAsync\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPassword recovery\u003C/td\u003E\n\u003Ctd\u003EUser cannot authenticate using the existing password\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EBeginResetAsync\u003C/code\u003E \u2192 \u003Ccode\u003ECompleteResetAsync\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EA password change verifies the existing secret.\u003C/p\u003E\n\u003Cp\u003EPassword recovery uses a separate proof mechanism, such as a reset token or code delivered through a trusted channel.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EDo not use administrative password changes as a replacement for public password recovery.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022begin-password-recovery\u0022\u003E7. Begin Password Recovery\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EBeginResetAsync\u003C/code\u003E to initiate a credential recovery workflow.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.BeginResetAsync(\n new BeginResetCredentialRequest\n {\n Identifier = \u0026quot;john@example.com\u0026quot;,\n CredentialType = CredentialType.Password,\n ResetCodeType = ResetCodeType.Token\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } reset)\n{\n Console.WriteLine($\u0026quot;Expires at: {reset.ExpiresAt}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;CredentialBeginResetResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022beginresetcredentialrequest\u0022\u003EBeginResetCredentialRequest\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifier\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser identifier associated with the recovery request\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential type to recover\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EResetCodeType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReset proof format\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022reset-code-types\u0022\u003EReset Code Types\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic enum ResetCodeType\n{\n Token = 0,\n Code = 10\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse \u003Ccode\u003EToken\u003C/code\u003E for a token-based recovery workflow.\u003C/p\u003E\n\u003Cp\u003EUse \u003Ccode\u003ECode\u003C/code\u003E for a code-based recovery workflow.\u003C/p\u003E\n\u003Cp\u003EThe delivery mechanism is determined by the application\u0027s configured server-side notification implementation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentialbeginresetresult\u0022\u003ECredentialBeginResetResult\u003C/h3\u003E\n\u003Cp\u003EThe result contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record CredentialBeginResetResult\n{\n public DateTimeOffset ExpiresAt { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EThe reset token or code is not returned in the API response.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThis prevents the recovery endpoint from directly exposing the secret needed to take over an account.\u003C/p\u003E\n\u003Cp\u003EThe proof must be delivered through the configured trusted notification channel.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022complete-password-recovery\u0022\u003E8. Complete Password Recovery\u003C/h2\u003E\n\u003Cp\u003EAfter the user receives a reset token or code, call \u003Ccode\u003ECompleteResetAsync\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.CompleteResetAsync(\n new CompleteResetCredentialRequest\n {\n Identifier = \u0026quot;john@example.com\u0026quot;,\n CredentialType = CredentialType.Password,\n ResetToken = resetToken,\n NewSecret = newPassword\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true })\n{\n // Password recovery completed.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;CredentialActionResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022completeresetcredentialrequest\u0022\u003ECompleteResetCredentialRequest\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifier\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier used for the recovery workflow\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential type being recovered\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EResetToken\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDelivered recovery proof\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENewSecret\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReplacement secret\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe property is named \u003Ccode\u003EResetToken\u003C/code\u003E even when the selected reset format is a code.\u003C/p\u003E\n\u003Cp\u003EUse the exact contract property name.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentialactionresult\u0022\u003ECredentialActionResult\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record CredentialActionResult\n{\n public bool IsSuccess { get; init; }\n public string? Error { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (!result.IsSuccess)\n{\n errorMessage = \u0026quot;Unable to complete password recovery.\u0026quot;;\n}\nelse if (result.Value is { IsSuccess: false } action)\n{\n errorMessage = action.Error ?? \u0026quot;Password recovery failed.\u0026quot;;\n}\nelse\n{\n successMessage = \u0026quot;Your password has been reset.\u0026quot;;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAfter a successful \u003Ccode\u003ECompleteResetAsync\u003C/code\u003E operation, the SDK publishes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthStateEvent.CredentialsChanged\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe event is not the same as \u003Ccode\u003ECredentialsChangedSelf\u003C/code\u003E, which is published by \u003Ccode\u003EChangeMyAsync\u003C/code\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022complete-recovery-using-a-numeric-code\u0022\u003E9. Complete Recovery Using a Numeric Code\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports code-based recovery through \u003Ccode\u003EResetCodeType.Code\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EBegin the recovery:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar begin = await UAuthClient.Credentials.BeginResetAsync(\n new BeginResetCredentialRequest\n {\n Identifier = email,\n CredentialType = CredentialType.Password,\n ResetCodeType = ResetCodeType.Code\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThen submit the code received by the user:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar complete = await UAuthClient.Credentials.CompleteResetAsync(\n new CompleteResetCredentialRequest\n {\n Identifier = email,\n CredentialType = CredentialType.Password,\n ResetToken = enteredCode,\n NewSecret = newPassword\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe same completion API handles token-based and code-based recovery.\u003C/p\u003E\n\u003Cp\u003EThe server is responsible for validating the submitted proof against the active recovery state.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022build-a-forgot-password-form\u0022\u003E10. Build a Forgot Password Form\u003C/h2\u003E\n\u003Cp\u003EA typical forgot-password interface has two stages:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003ERequest a recovery token or code.\u003C/li\u003E\n\u003Cli\u003ESubmit the received proof with a new password.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022step-one-request-recovery\u0022\u003EStep One: Request Recovery\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@using CodeBeam.UltimateAuth.Core.Domain\n@using CodeBeam.UltimateAuth.Credentials.Contracts\n@inject IUAuthClient UAuthClient\n\n\u0026lt;h3\u0026gt;Forgot Password\u0026lt;/h3\u0026gt;\n\n\u0026lt;input @bind=\u0026quot;_email\u0026quot; type=\u0026quot;email\u0026quot; placeholder=\u0026quot;Email address\u0026quot; /\u0026gt;\n\n\u0026lt;button type=\u0026quot;button\u0026quot; disabled=\u0026quot;@_busy\u0026quot; @onclick=\u0026quot;BeginRecoveryAsync\u0026quot;\u0026gt;Send recovery instructions\u0026lt;/button\u0026gt;\n\n@if (_message is not null)\n{\n \u0026lt;p role=\u0026quot;status\u0026quot;\u0026gt;@_message\u0026lt;/p\u0026gt;\n}\n\n@code {\n private string _email = \u0026quot;\u0026quot;;\n private string? _message;\n private bool _busy;\n\n private async Task BeginRecoveryAsync()\n {\n if (_busy || string.IsNullOrWhiteSpace(_email))\n return;\n\n _busy = true;\n\n try\n {\n var result = await UAuthClient.Credentials.BeginResetAsync(\n new BeginResetCredentialRequest\n {\n Identifier = _email,\n CredentialType = CredentialType.Password,\n ResetCodeType = ResetCodeType.Token\n });\n\n _message = result.IsSuccess\n ? \u0026quot;If the account is eligible, recovery instructions will be sent.\u0026quot;\n : \u0026quot;Unable to process the recovery request.\u0026quot;;\n }\n catch (Exception)\n {\n _message = \u0026quot;Recovery is temporarily unavailable.\u0026quot;;\n }\n finally\n {\n _busy = false;\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe confirmation message intentionally avoids revealing whether an account exists.\u003C/p\u003E\n\u003Cp\u003EApplications should avoid introducing account enumeration through UI messages, timing differences, or diagnostic details.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022step-two-complete-recovery\u0022\u003EStep Two: Complete Recovery\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@using CodeBeam.UltimateAuth.Core.Domain\n@using CodeBeam.UltimateAuth.Credentials.Contracts\n@inject IUAuthClient UAuthClient\n\n\u0026lt;h3\u0026gt;Reset Password\u0026lt;/h3\u0026gt;\n\n\u0026lt;input @bind=\u0026quot;_email\u0026quot; placeholder=\u0026quot;Email address\u0026quot; /\u0026gt;\n\u0026lt;input @bind=\u0026quot;_token\u0026quot; placeholder=\u0026quot;Recovery token or code\u0026quot; /\u0026gt;\n\u0026lt;input @bind=\u0026quot;_newPassword\u0026quot; type=\u0026quot;password\u0026quot; placeholder=\u0026quot;New password\u0026quot; /\u0026gt;\n\n\u0026lt;button type=\u0026quot;button\u0026quot; disabled=\u0026quot;@_busy\u0026quot; @onclick=\u0026quot;CompleteRecoveryAsync\u0026quot;\u0026gt;Reset password\u0026lt;/button\u0026gt;\n\n@if (_message is not null)\n{\n \u0026lt;p role=\u0026quot;status\u0026quot;\u0026gt;@_message\u0026lt;/p\u0026gt;\n}\n\n@code {\n private string _email = \u0026quot;\u0026quot;;\n private string _token = \u0026quot;\u0026quot;;\n private string _newPassword = \u0026quot;\u0026quot;;\n\n private string? _message;\n private bool _busy;\n\n private async Task CompleteRecoveryAsync()\n {\n if (_busy)\n return;\n\n _busy = true;\n _message = null;\n\n try\n {\n var result = await UAuthClient.Credentials.CompleteResetAsync(\n new CompleteResetCredentialRequest\n {\n Identifier = _email,\n CredentialType = CredentialType.Password,\n ResetToken = _token,\n NewSecret = _newPassword\n });\n\n if (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true })\n {\n _token = \u0026quot;\u0026quot;;\n _newPassword = \u0026quot;\u0026quot;;\n _message = \u0026quot;Password reset successfully.\u0026quot;;\n }\n else\n {\n _message = \u0026quot;Unable to reset password.\u0026quot;;\n }\n }\n catch (Exception)\n {\n _message = \u0026quot;Recovery is temporarily unavailable.\u0026quot;;\n }\n finally\n {\n _busy = false;\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese examples show the SDK integration.\u003C/p\u003E\n\u003Cp\u003EA production implementation should also validate user input, protect recovery proofs from accidental disclosure, and avoid logging reset tokens or passwords.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-reset-notifications\u0022\u003E11. Configure Reset Notifications\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth uses \u003Ccode\u003EICredentialResetNotifier\u003C/code\u003E to deliver recovery tokens and codes.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic interface ICredentialResetNotifier\n{\n Task NotifyAsync(CredentialResetNotification notification, CancellationToken ct = default);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe notification includes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record CredentialResetNotification(\n TenantKey Tenant,\n UserKey UserKey,\n CredentialType CredentialType,\n ResetCodeType CodeType,\n string Token,\n DateTimeOffset ExpiresAt);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe application must provide a secure delivery implementation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022register-the-notifier\u0022\u003ERegister the Notifier\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddScoped\u0026lt;ICredentialResetNotifier, ApplicationCredentialResetNotifier\u0026gt;();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EApplicationCredentialResetNotifier\u003C/code\u003E is an application-defined implementation.\u003C/p\u003E\n\u003Cp\u003EIt might deliver the proof through email, SMS, or another trusted channel associated with the user\u0027s account.\u003C/p\u003E\n\u003Cp\u003EThe notifier should not expose the proof to an arbitrary address supplied by an unauthenticated caller.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022missing-notifier-configuration\u0022\u003EMissing Notifier Configuration\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth provides a \u003Ccode\u003ENotConfiguredCredentialResetNotifier\u003C/code\u003E fallback.\u003C/p\u003E\n\u003Cp\u003EWhen recovery requires notification but no implementation has been configured, the fallback throws an explanatory exception.\u003C/p\u003E\n\u003Cp\u003EThis prevents a reset flow from silently succeeding without a configured proof delivery mechanism.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ENever return recovery tokens to anonymous clients as a substitute for configuring the notifier.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022recovery-security-behavior\u0022\u003E12. Recovery Security Behavior\u003C/h2\u003E\n\u003Cp\u003ECredential recovery is designed around several security properties.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022account-enumeration-resistance\u0022\u003EAccount Enumeration Resistance\u003C/h3\u003E\n\u003Cp\u003ERecovery initiation should not reveal whether a submitted identifier belongs to an account.\u003C/p\u003E\n\u003Cp\u003EApplications should preserve this behavior in their own UI and logging policies.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022proof-expiration\u0022\u003EProof Expiration\u003C/h3\u003E\n\u003Cp\u003ERecovery proofs have a server-controlled lifetime.\u003C/p\u003E\n\u003Cp\u003EThe client does not choose an arbitrary expiration time.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022single-use-proofs\u0022\u003ESingle-Use Proofs\u003C/h3\u003E\n\u003Cp\u003EA successfully consumed recovery proof must not be reusable.\u003C/p\u003E\n\u003Cp\u003EAfter successful completion, a second attempt using the same proof should be rejected.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-and-target-binding\u0022\u003EIdentifier and Target Binding\u003C/h3\u003E\n\u003Cp\u003ERecovery must apply to the correct user and credential.\u003C/p\u003E\n\u003Cp\u003EAdministrative recovery must not permit an identifier belonging to one user to reset another user\u0027s credential.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-revocation\u0022\u003ESession Revocation\u003C/h3\u003E\n\u003Cp\u003ESuccessful recovery may invalidate existing sessions according to the configured credential session policy.\u003C/p\u003E\n\u003Cp\u003EBy default, UltimateAuth revokes all session chains after recovery.\u003C/p\u003E\n\u003Cp\u003EFor further details, see \u003Ca href=\u0022/docs/security/session-security\u0022\u003ESession Security Model\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-your-credential\u0022\u003E13. Revoke Your Credential\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERevokeMyAsync\u003C/code\u003E to revoke a credential belonging to the authenticated user.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.RevokeMyAsync(\n new RevokeCredentialRequest\n {\n Id = credentialId\n });\n\nif (result.IsSuccess)\n{\n // Credential revoked.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe request contract is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record RevokeCredentialRequest\n{\n public Guid Id { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse the credential record ID, not a user ID or session ID.\u003C/p\u003E\n\u003Cp\u003ECredential revocation is distinct from credential deletion.\u003C/p\u003E\n\u003Cp\u003ERevocation changes the credential\u0027s usability, while deletion uses a separate lifecycle operation available through the administrative API.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022state-event\u0022\u003EState Event\u003C/h3\u003E\n\u003Cp\u003EAfter successful \u003Ccode\u003ERevokeMyAsync\u003C/code\u003E, the SDK publishes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthStateEvent.CredentialsChanged\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe application can use this event to refresh credential-related UI.\u003C/p\u003E\n\u003Cp\u003ECredential revocation may affect authentication eligibility and session continuation depending on server policy.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-credential-management\u0022\u003E14. Administrative Credential Management\u003C/h2\u003E\n\u003Cp\u003EAdministrative credential operations target a specific user.\u003C/p\u003E\n\u003Cp\u003EThey require appropriate server-side authorization.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022add-a-credential-1\u0022\u003EAdd a Credential\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.AddUserAsync(userKey,\n new AddCredentialRequest\n {\n Secret = temporaryPassword\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true } added)\n{\n Console.WriteLine(added.Id);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server validates whether the administrative actor may add a credential for the target user.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022change-a-users-password\u0022\u003EChange a User\u0027s Password\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.ChangeUserAsync(userKey,\n new ChangeCredentialRequest\n {\n NewSecret = newPassword\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true })\n{\n // Administrative password change succeeded.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ECurrentSecret\u003C/code\u003E is not required for an authorized administrative credential change.\u003C/p\u003E\n\u003Cp\u003EThis makes administrative credential changes a privileged operation.\u003C/p\u003E\n\u003Cp\u003EDo not expose this capability through ordinary self-service UI.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-a-users-credential\u0022\u003ERevoke a User\u0027s Credential\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.RevokeUserAsync(userKey,\n new RevokeCredentialRequest\n {\n Id = credentialId\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022begin-administrative-recovery\u0022\u003EBegin Administrative Recovery\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.BeginResetUserAsync(userKey,\n new BeginResetCredentialRequest\n {\n Identifier = userEmail,\n CredentialType = CredentialType.Password,\n ResetCodeType = ResetCodeType.Token\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022complete-administrative-recovery\u0022\u003EComplete Administrative Recovery\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.CompleteResetUserAsync(userKey,\n new CompleteResetCredentialRequest\n {\n Identifier = userEmail,\n CredentialType = CredentialType.Password,\n ResetToken = recoveryToken,\n NewSecret = newPassword\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAdministrative recovery remains a proof-based workflow.\u003C/p\u003E\n\u003Cp\u003EThe server validates the relationship between the target user and the supplied identifier.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022delete-a-users-credential\u0022\u003EDelete a User\u0027s Credential\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.DeleteUserAsync(userKey,\n new DeleteCredentialRequest\n {\n Id = credentialId,\n Mode = DeleteMode.Soft\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022hard-delete\u0022\u003EHard Delete\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.DeleteUserAsync(userKey,\n new DeleteCredentialRequest\n {\n Id = credentialId,\n Mode = DeleteMode.Hard\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EHard deletion is a destructive administrative operation.\u003C/p\u003E\n\u003Cp\u003EIts availability and effects depend on server authorization and credential lifecycle policies.\u003C/p\u003E\n\u003Cp\u003EAdministrative operations do not automatically publish the same local credential state events as self-service operations.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-changes-and-session-invalidation\u0022\u003E15. Credential Changes and Session Invalidation\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth allows applications to configure how existing sessions are handled after credential changes and recovery.\u003C/p\u003E\n\u003Cp\u003EThe server exposes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthServerOptions.CredentialSessionPolicy\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe policy contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOption\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnSelfChange\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveCurrent\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESessions after self-service credential changes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnAdminChange\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESessions after administrative credential changes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnRecovery\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESessions after credential recovery\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentialsessionbehavior\u0022\u003ECredentialSessionBehavior\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic enum CredentialSessionBehavior\n{\n PreserveAll = 0,\n PreserveCurrent = 10,\n RevokeAll = 20\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EBehavior\u003C/th\u003E\n\u003Cth\u003EMeaning\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPreserve existing session chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveCurrent\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPreserve the current chain and revoke other chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke all applicable session chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-policy\u0022\u003EConfigure the Policy\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.CredentialSessionPolicy.OnSelfChange = CredentialSessionBehavior.PreserveCurrent;\n options.CredentialSessionPolicy.OnAdminChange = CredentialSessionBehavior.RevokeAll;\n options.CredentialSessionPolicy.OnRecovery = CredentialSessionBehavior.RevokeAll;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe defaults are intended to balance usability and security.\u003C/p\u003E\n\u003Cp\u003EFor example, an authenticated user changing their password can retain their current device while other devices are invalidated.\u003C/p\u003E\n\u003Cp\u003EAn administrator changing a user\u0027s password typically requires broader session revocation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022recovery-and-preservecurrent\u0022\u003ERecovery and PreserveCurrent\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EPreserveCurrent\u003C/code\u003E is not meaningful for many recovery workflows because the user may not have an authenticated current session.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth\u0027s recovery policy documentation specifies that \u003Ccode\u003EPreserveCurrent\u003C/code\u003E is treated as \u003Ccode\u003ERevokeAll\u003C/code\u003E in this context.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-mode-considerations\u0022\u003EAuthentication Mode Considerations\u003C/h3\u003E\n\u003Cp\u003EThe precise effect of session invalidation depends on the authentication mode.\u003C/p\u003E\n\u003Cp\u003EIn particular, a locally validated JWT access token may remain usable until expiration if the resource server does not consult current revocation state.\u003C/p\u003E\n\u003Cp\u003ECredential session policies should be understood together with the application\u0027s token validation strategy.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/security/token-security\u0022\u003EToken Security\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022handling-credential-results\u0022\u003E16. Handling Credential Results\u003C/h2\u003E\n\u003Cp\u003ECredential operations use \u003Ccode\u003EUAuthResult\u003C/code\u003E or \u003Ccode\u003EUAuthResult\u0026lt;T\u0026gt;\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003ESeveral operations also return a domain-level success flag.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-creation\u0022\u003ECredential Creation\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.AddMyAsync(request);\n\nif (!result.IsSuccess)\n{\n // API-level failure.\n}\nelse if (result.Value is { IsSuccess: false } add)\n{\n // Credential creation was rejected.\n Console.WriteLine(add.Error);\n}\nelse if (result.Value is { IsSuccess: true } added)\n{\n Console.WriteLine(added.Id);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-recovery\u0022\u003ECredential Recovery\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.CompleteResetAsync(request);\n\nif (!result.IsSuccess)\n{\n // API-level failure.\n}\nelse if (result.Value is { IsSuccess: false } action)\n{\n // Recovery did not complete successfully.\n}\nelse\n{\n // Recovery succeeded.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022http-status-helpers\u0022\u003EHTTP Status Helpers\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (result.Http.IsUnauthorized)\n{\n // Authentication required.\n}\nelse if (result.Http.IsForbidden)\n{\n // Operation not permitted.\n}\nelse if (result.Http.IsConflict)\n{\n // Conflicting or expired operation state.\n}\nelse if (result.Http.IsBadRequest)\n{\n // Invalid request.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe actual status depends on the server\u0027s response.\u003C/p\u003E\n\u003Cp\u003EAvoid exposing sensitive internal credential errors directly to end users.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-state-events\u0022\u003E17. Credential State Events\u003C/h2\u003E\n\u003Cp\u003EThe current \u003Ccode\u003EUAuthCredentialClient\u003C/code\u003E publishes the following events after successful self-service operations.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003EEvent\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EChangeMyAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialsChangedSelf\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeMyAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialsChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECompleteResetAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialsChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe following operations do not publish those events in the current client implementation:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EAddMyAsync\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EBeginResetAsync\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003EAdministrative credential operations\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EApplications should refresh relevant data explicitly where needed.\u003C/p\u003E\n\u003Cp\u003EEvents help synchronize client state but do not themselves establish that a credential or session has been invalidated on the server.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-considerations\u0022\u003E18. Security Considerations\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022never-expose-stored-secrets\u0022\u003ENever Expose Stored Secrets\u003C/h3\u003E\n\u003Cp\u003ECredential secrets must not be returned through credential management responses.\u003C/p\u003E\n\u003Cp\u003EApplications should never log passwords, reset proofs, or other authentication secrets.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022use-secure-transport\u0022\u003EUse Secure Transport\u003C/h3\u003E\n\u003Cp\u003ECredential operations must be performed over appropriately secured transport.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022protect-recovery-proofs\u0022\u003EProtect Recovery Proofs\u003C/h3\u003E\n\u003Cp\u003ERecovery tokens and codes should be short-lived, single-use, and delivered through trusted channels.\u003C/p\u003E\n\u003Cp\u003EDo not store recovery proofs in persistent client storage unless the application has a carefully justified security requirement.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022keep-self-service-and-administrative-operations-separate\u0022\u003EKeep Self-Service and Administrative Operations Separate\u003C/h3\u003E\n\u003Cp\u003ESelf-service changes require the current credential.\u003C/p\u003E\n\u003Cp\u003EAdministrative changes are privileged and require server-side authorization.\u003C/p\u003E\n\u003Cp\u003EDo not substitute one for the other.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022preserve-account-recovery-safety\u0022\u003EPreserve Account Recovery Safety\u003C/h3\u003E\n\u003Cp\u003EAvoid revealing account existence through public recovery responses.\u003C/p\u003E\n\u003Cp\u003EApply appropriate rate limiting, proof attempt limits, and abuse controls.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022consider-session-consequences\u0022\u003EConsider Session Consequences\u003C/h3\u003E\n\u003Cp\u003EChanging or resetting a password can affect existing sessions.\u003C/p\u003E\n\u003Cp\u003EApplications should explain the expected consequences to users, especially when other devices will be logged out.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022do-not-assume-every-credential-type-is-implemented\u0022\u003EDo Not Assume Every Credential Type Is Implemented\u003C/h3\u003E\n\u003Cp\u003EThe existence of a \u003Ccode\u003ECredentialType\u003C/code\u003E enum member does not establish that its complete lifecycle is supported.\u003C/p\u003E\n\u003Cp\u003EUse the currently implemented credential contracts and documented authentication flows.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022retrieving-password-requirements\u0022\u003ERetrieving Password Requirements\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth clients can retrieve the effective password policy configured by the authentication server.\u003C/p\u003E\n\u003Cp\u003EThis allows applications to display the current requirements during registration, user creation, and password changes without hardcoding password rules in the UI.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.GetPasswordPolicyAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } policy)\n{\n // Display the effective requirements.\n var minimumLength = policy.MinLength;\n var maximumLength = policy.MaxLength;\n var level = policy.Level;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe response includes the configured policy level, effective length and character requirements, and indicators for server-side common-password, user-similarity, and password-history checks.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-side-validation\u0022\u003EClient-Side Validation\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003EUAuthPasswordClientValidator\u003C/code\u003E to provide immediate feedback before submitting a password:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client;\n\nvar errors = UAuthPasswordClientValidator.Validate(\n password,\n policy);\n\nif (errors.Count == 0)\n{\n // Local character and length requirements are satisfied.\n // The server must still validate the submitted password.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe validator returns \u003Ccode\u003EPasswordPolicyErrorCodes\u003C/code\u003E values for failed local requirements.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EClient-side validation is advisory, not authoritative.\u003C/strong\u003E The server remains responsible for the complete password-policy decision, including common-password detection, user similarity, and password history.\u003C/p\u003E\n\u003Cp\u003EFor the built-in Basic, Standard, and Strict requirements, configuration examples, and password-history behavior, see \u003Ca href=\u0022/docs/security/password-policy\u0022\u003EPassword Policy \u0026amp; History\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/authentication\u0022\u003EAuthentication\u003C/a\u003E \u2014 Login, logout, refresh, and authentication flows.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/identifiers\u0022\u003EIdentifiers\u003C/a\u003E \u2014 Manage user identifiers and ownership verification.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/user-management\u0022\u003EUser Management\u003C/a\u003E \u2014 Manage profiles and user lifecycle.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/session-management\u0022\u003ESession Management\u003C/a\u003E \u2014 Inspect and revoke sessions.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/security/token-security\u0022\u003EToken Security\u003C/a\u003E \u2014 Understand token validation and revocation behavior.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/security/session-security\u0022\u003ESession Security Model\u003C/a\u003E \u2014 Understand session invalidation and account-level security boundaries.\u003C/li\u003E\n\u003C/ul\u003E\n", "Headings": [ { "Id": "understanding-credentials", @@ -298,12 +298,22 @@ "Text": "Do Not Assume Every Credential Type Is Implemented", "Level": 1 }, + { + "Id": "retrieving-password-requirements", + "Text": "Retrieving Password Requirements", + "Level": 0 + }, + { + "Id": "client-side-validation", + "Text": "Client-Side Validation", + "Level": 1 + }, { "Id": "next-steps", "Text": "Next Steps", "Level": 0 } ], - "SourceHash": "DF1EFE42FAB4410FFCA4565BFB89489FAA283C600459D65A80F5FE5BE0DF1044", + "SourceHash": "8A73382F304D6EC208718BEDC7D300B180A397144C25FB5FCC4BCC3F330FAF8F", "BuilderVersion": "1" } \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/docs-index.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/docs-index.json index fde9e879..0a8a077b 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/docs-index.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/docs-index.json @@ -272,6 +272,13 @@ "Group": "security", "GroupOrder": 8 }, + { + "Title": "Password Policy \u0026 History", + "Slug": "security/password-policy", + "Order": 6, + "Group": "security", + "GroupOrder": 8 + }, { "Title": "Rate Limiting", "Slug": "security/rate-limiting", diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/getting-started/real-world-setup.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/getting-started/real-world-setup.json index 17e02bae..819aab59 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/getting-started/real-world-setup.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/getting-started/real-world-setup.json @@ -1,7 +1,7 @@ { "Slug": "getting-started/real-world-setup", "Title": "Real World Setup", - "Html": "\n\u003Cp\u003E\u003Cstrong\u003EOne authentication framework. Different deployment models.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThe \u003Ca href=\u0022/docs/getting-started/quickstart\u0022\u003EQuickStart\u003C/a\u003E demonstrates UltimateAuth with Blazor Server and in-memory storage.\u003C/p\u003E\n\u003Cp\u003EReal applications may require persistent data, a separate authentication server, browser-based clients, or independently deployed APIs.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth supports these scenarios through different service registrations and hosting integrations. You can choose the architecture that fits your application without configuring every available component.\u003C/p\u003E\n\u003Cp\u003EThis guide covers the setup patterns demonstrated by the UltimateAuth samples.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022choose-your-architecture\u0022\u003E1. Choose Your Architecture\u003C/h2\u003E\n\u003Cp\u003EStart by identifying where authentication is hosted and how your application communicates with it.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EApplication architecture\u003C/th\u003E\n\u003Cth\u003EAuthentication host\u003C/th\u003E\n\u003Cth\u003EApplication registration\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Server, single project\u003C/td\u003E\n\u003Ctd\u003ESame application\u003C/td\u003E\n\u003Ctd\u003EServer \u002B persistence \u002B Blazor client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Server with EF Core\u003C/td\u003E\n\u003Ctd\u003ESame application\u003C/td\u003E\n\u003Ctd\u003EServer \u002B EF Core \u002B Blazor client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EDedicated UAuthHub\u003C/td\u003E\n\u003Ctd\u003ESeparate authentication application\u003C/td\u003E\n\u003Ctd\u003EServer \u002B persistence \u002B Hub\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EStandalone Blazor WASM\u003C/td\u003E\n\u003Ctd\u003ERemote UAuthHub\u003C/td\u003E\n\u003Ctd\u003EBlazor client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Web App, Interactive WASM\u003C/td\u003E\n\u003Ctd\u003ERemote UAuthHub\u003C/td\u003E\n\u003Ctd\u003EHost integration \u002B WASM client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESeparate Resource API\u003C/td\u003E\n\u003Ctd\u003ERemote UAuthHub\u003C/td\u003E\n\u003Ctd\u003EResource API integration\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese models can be combined.\u003C/p\u003E\n\u003Cp\u003EFor example, an application might use an EF Core-backed UAuthHub, a standalone Blazor WASM frontend, and a separate Resource API.\u003C/p\u003E\n\u003Cp\u003EThe frontend handles user interaction, UAuthHub manages authentication, and the Resource API enforces access to protected resources.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022persistent-storage-with-entity-framework-core\u0022\u003E2. Persistent Storage with Entity Framework Core\u003C/h2\u003E\n\u003Cp\u003EFor applications that need authentication state to survive restarts, use a persistent storage provider.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth provides an Entity Framework Core bundle that registers its reference domain implementations and EF Core-backed stores.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022install-packages\u0022\u003EInstall packages\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle\ndotnet add package Microsoft.EntityFrameworkCore.Sqlite\ndotnet add package Microsoft.EntityFrameworkCore.Design\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe SQLite provider is used here for simplicity. You can use another compatible EF Core database provider.\u003C/p\u003E\n\u003Cp\u003EWhen using the EF Core bundle, you do not need the in-memory bundle.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022register-the-services\u0022\u003ERegister the services\u003C/h3\u003E\n\u003Cp\u003EIn \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.EntityFrameworkCore;\nusing CodeBeam.UltimateAuth.Server.Extensions;\nusing Microsoft.EntityFrameworkCore;\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(\u0026quot;Data Source=uauth.db\u0026quot;, sqlite =\u0026gt; sqlite.MigrationsAssembly(typeof(Program).Assembly.GetName().Name!));\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EAddUltimateAuthEntityFrameworkCore()\u003C/code\u003E registers the EF Core stores and reference domain implementations.\u003C/p\u003E\n\u003Cp\u003EThe bundle provides a unified \u003Ccode\u003EUAuthDbContext\u003C/code\u003E for the supported authentication domains.\u003C/p\u003E\n\u003Cp\u003EYou do not need to register each user, session, credential, token, or authorization store individually.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-database-connection\u0022\u003EConfigure the database connection\u003C/h3\u003E\n\u003Cp\u003EFor a deployed application, prefer configuration over hardcoded connection strings.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003Eappsettings.json\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;ConnectionStrings\u0026quot;: {\n \u0026quot;UltimateAuth\u0026quot;: \u0026quot;Data Source=uauth.db\u0026quot;\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar connectionString = builder.Configuration.GetConnectionString(\u0026quot;UltimateAuth\u0026quot;)\n ?? throw new InvalidOperationException(\u0026quot;UltimateAuth connection string is missing.\u0026quot;);\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(connectionString);\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor production databases, use your deployment environment\u0027s secret management facilities rather than storing credentials in source control.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022database-migrations\u0022\u003EDatabase migrations\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth does not automatically create or apply migrations when the EF Core bundle is registered.\u003C/p\u003E\n\u003Cp\u003EYour application owns the database migration lifecycle.\u003C/p\u003E\n\u003Cp\u003EInstall the EF Core CLI if necessary:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet tool install --global dotnet-ef\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ECreate the initial migration:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet ef migrations add InitUAuth --context UAuthDbContext\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EApply it:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet ef database update --context UAuthDbContext\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor Visual Studio Package Manager Console:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EPOWERSHELL\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-powershell\u0022\u003EAdd-Migration InitUAuth -Context UAuthDbContext\nUpdate-Database -Context UAuthDbContext\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EInstall \u003Ccode\u003EMicrosoft.EntityFrameworkCore.Tools\u003C/code\u003E if required.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022applying-migrations-programmatically\u0022\u003EApplying migrations programmatically\u003C/h3\u003E\n\u003Cp\u003EThe EF Core samples demonstrate initialization through a scoped \u003Ccode\u003EUAuthDbContext\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor a controlled development environment:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.EntityFrameworkCore;\nusing Microsoft.EntityFrameworkCore;\n\nusing (var scope = app.Services.CreateScope())\n{\n var db = scope.ServiceProvider.GetRequiredService\u0026lt;UAuthDbContext\u0026gt;();\n await db.Database.MigrateAsync();\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis applies pending migrations. It does not generate migration files.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EProduction guidance:\u003C/strong\u003E Prefer applying reviewed migrations as a separate deployment step, particularly when multiple application instances share a database.\u003C/p\u003E\n\u003Cp\u003EDo not copy the sample initializer\u0027s \u003Ccode\u003Ereset: true\u003C/code\u003E behavior into production. The samples use database reset functionality for repeatable demonstrations, not as a safe deployment strategy.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022optional-sample-accounts-with-ef-core\u0022\u003EOptional: sample accounts with EF Core\u003C/h3\u003E\n\u003Cp\u003EThe sample seed package can also work with EF Core persistence.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Sample.Seed\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERegister the scoped seed services:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddScopedUltimateAuthSampleSeed();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAfter preparing the database:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (app.Environment.IsDevelopment())\n{\n await app.SeedUltimateAuthAsync();\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe difference matters:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EAddUltimateAuthSampleSeed()\u003C/code\u003E is used by the in-memory samples.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EAddScopedUltimateAuthSampleSeed()\u003C/code\u003E is used by the EF Core samples.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EDo not deploy the demonstration accounts and passwords to production.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-server-integrated-authentication\u0022\u003E3. Blazor Server \u2014 Integrated Authentication\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E A Blazor Server application that wants to host its own authentication system.\u003C/p\u003E\n\u003Cp\u003EThis is the simplest deployment model for a server-side Blazor application.\u003C/p\u003E\n\u003Cp\u003EThe application hosts UltimateAuth Server and uses the Blazor client adapter in the same project.\u003C/p\u003E\n\u003Cp\u003EA separate UAuthHub is not required.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022packages\u0022\u003EPackages\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\ndotnet add package CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle\ndotnet add package Microsoft.EntityFrameworkCore.Sqlite\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022services\u0022\u003EServices\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.EntityFrameworkCore;\nusing CodeBeam.UltimateAuth.Server.Extensions;\nusing Microsoft.EntityFrameworkCore;\n\nbuilder.Services\n .AddRazorComponents()\n .AddInteractiveServerComponents();\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(\u0026quot;Data Source=uauth.db\u0026quot;);\n });\n\nbuilder.Services.AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022pipeline\u0022\u003EPipeline\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseHttpsRedirection();\napp.UseStaticFiles();\n\napp.UseUltimateAuthWithAspNetCore();\napp.UseAntiforgery();\n\napp.MapUltimateAuthEndpoints();\n\napp.MapRazorComponents\u0026lt;App\u0026gt;()\n .AddInteractiveServerRenderMode()\n .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient());\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAlso configure the \u003Ccode\u003EUAuthApp\u003C/code\u003E root component, login page, and UltimateAuth JavaScript file as demonstrated in QuickStart.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-behavior\u0022\u003EAuthentication behavior\u003C/h3\u003E\n\u003Cp\u003EThe default client profile resolution selects \u003Ccode\u003EPureOpaque\u003C/code\u003E for Blazor Server.\u003C/p\u003E\n\u003Cp\u003EThis uses session-oriented authentication rather than requiring the application to manage access and refresh tokens manually.\u003C/p\u003E\n\u003Cp\u003EYou can configure session lifetimes, login policies, credential policies, and other behavior through \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n options.Login.LockoutDuration = TimeSpan.FromMinutes(15);\n options.Session.Lifetime = TimeSpan.FromDays(7);\n options.Identifiers.Behavior.AllowMultipleUsernames = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe effective authentication mode is resolved at runtime. The application does not need to select a single mode globally.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022uauthhub-dedicated-authentication-server\u0022\u003E4. UAuthHub \u2014 Dedicated Authentication Server\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E Applications where multiple clients share one authentication authority.\u003C/p\u003E\n\u003Cp\u003EUAuthHub hosts UltimateAuth\u0027s authentication services separately from your frontend applications.\u003C/p\u003E\n\u003Cp\u003EIt is particularly relevant for standalone WASM applications, Interactive WASM applications, and systems with separate Resource APIs.\u003C/p\u003E\n\u003Cp\u003EA typical architecture looks like this:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E Standalone WASM Interactive WASM\n \u2502 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2502\n \u25BC\n UAuthHub\n Authentication\n Sessions \u0026amp; Users\n \u2502\n \u25BC\n Persistence\n\n Resource API \u2500\u2500\u2500\u25BA UAuthHub\n (validation)\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022create-the-hub-application\u0022\u003ECreate the Hub application\u003C/h3\u003E\n\u003Cp\u003EA UAuthHub can be hosted in an ASP.NET Core Blazor Server application.\u003C/p\u003E\n\u003Cp\u003EThe current samples use Blazor Server to provide the Hub\u0027s login UI.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022in-memory-uauthhub\u0022\u003EIn-memory UAuthHub\u003C/h3\u003E\n\u003Cp\u003EFor development:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\ndotnet add package CodeBeam.UltimateAuth.InMemory.Bundle\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERegister:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.InMemory;\nusing CodeBeam.UltimateAuth.Server.Extensions;\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthInMemory()\n .AddUAuthHub(options =\u0026gt;\n {\n options.AllowedClientOrigins.Add(\u0026quot;https://localhost:6130\u0026quot;);\n });\n\nbuilder.Services.AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Hub\u0027s Blazor client registration is needed when the Hub itself hosts UltimateAuth-powered Blazor pages and login UI.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022ef-core-uauthhub\u0022\u003EEF Core UAuthHub\u003C/h3\u003E\n\u003Cp\u003EFor persistent storage:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.EntityFrameworkCore;\nusing Microsoft.EntityFrameworkCore;\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(\u0026quot;Data Source=uauthhub.db\u0026quot;);\n })\n .AddUAuthHub(options =\u0026gt;\n {\n options.AllowedClientOrigins.Add(\u0026quot;https://localhost:6132\u0026quot;);\n });\n\nbuilder.Services.AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe \u003Ccode\u003EAllowedClientOrigins\u003C/code\u003E collection identifies browser origins permitted to interact with the Hub.\u003C/p\u003E\n\u003Cp\u003EUse exact origins, including scheme and port. Do not include paths such as \u003Ccode\u003E/home\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022hub-pipeline\u0022\u003EHub pipeline\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseHttpsRedirection();\n\napp.UseUltimateAuthWithAspNetCore();\napp.UseAntiforgery();\n\napp.MapUltimateAuthEndpoints();\napp.MapUAuthHub();\n\napp.MapStaticAssets();\n\napp.MapRazorComponents\u0026lt;App\u0026gt;()\n .AddInteractiveServerRenderMode()\n .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient());\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EMapUltimateAuthEndpoints()\u003C/code\u003E maps the authentication endpoints.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EMapUAuthHub()\u003C/code\u003E maps the Hub-specific entry endpoint.\u003C/p\u003E\n\u003Cp\u003EBoth registrations are used by the current UAuthHub samples.\u003C/p\u003E\n\u003Cp\u003EThe Hub also needs its login page and Blazor application lifecycle components.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-origins\u0022\u003EChoosing origins\u003C/h3\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EApplication\u003C/th\u003E\n\u003Cth\u003EURL\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUAuthHub\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ehttps://localhost:6112\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EStandalone WASM\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ehttps://localhost:6130\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EInteractive WASM\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ehttps://localhost:6132\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EConfigure the Hub\u0027s allowed origins for the actual frontend applications it serves.\u003C/p\u003E\n\u003Cp\u003EThese localhost addresses are illustrative and must match your deployment.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-standalone-webassembly\u0022\u003E5. Blazor Standalone WebAssembly\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E A browser-hosted application with a separate UAuthHub.\u003C/p\u003E\n\u003Cp\u003EStandalone Blazor WASM runs in the browser. It does not host UltimateAuth Server or database persistence.\u003C/p\u003E\n\u003Cp\u003EAuthentication is handled by the remote UAuthHub.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-packages\u0022\u003EClient packages\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-services\u0022\u003EConfigure services\u003C/h3\u003E\n\u003Cp\u003EIn the standalone WASM application\u0027s \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.Core.Domain;\n\nvar builder = WebAssemblyHostBuilder.CreateDefault(args);\n\nbuilder.RootComponents.Add\u0026lt;App\u0026gt;(\u0026quot;#app\u0026quot;);\nbuilder.RootComponents.Add\u0026lt;HeadOutlet\u0026gt;(\u0026quot;head::after\u0026quot;);\n\nbuilder.Services.AddScoped(sp =\u0026gt; new HttpClient\n{\n BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)\n});\n\nbuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6110/auth\u0026quot;;\n options.Pkce.ReturnUrl = \u0026quot;https://localhost:6130/home\u0026quot;;\n options.Login.AllowCredentialPost = true;\n options.Reauth.Behavior = ReauthBehavior.RaiseEvent;\n});\n\nawait builder.Build().RunAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe current standalone WASM sample explicitly configures \u003Ccode\u003ELogin.AllowCredentialPost\u003C/code\u003E and the PKCE return URL.\u003C/p\u003E\n\u003Cp\u003EThese are client-side settings. The UAuthHub must be configured independently.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022what-the-urls-mean\u0022\u003EWhat the URLs mean\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EEndpoints.BasePath\u003C/code\u003E points to the remote authentication endpoints.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EPkce.ReturnUrl\u003C/code\u003E identifies the frontend destination used by the relevant PKCE flow.\u003C/p\u003E\n\u003Cp\u003EThe Hub must recognize the frontend\u0027s origin, and any return URL used in an authentication flow must be validated against the application\u0027s security policy.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022root-component\u0022\u003ERoot component\u003C/h3\u003E\n\u003Cp\u003EA standalone WASM application should initialize UltimateAuth through \u003Ccode\u003EUAuthApp\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor example, its root application or routing component can contain:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client.Blazor\n\n\u0026lt;UAuthApp UseBuiltInRouter=\u0026quot;true\u0026quot; AppAssembly=\u0026quot;typeof(Program).Assembly\u0026quot; DefaultLayout=\u0026quot;typeof(Layout.MainLayout)\u0026quot; /\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EInclude the UltimateAuth JavaScript asset in the application\u0027s HTML host page.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-modes\u0022\u003EAuthentication modes\u003C/h3\u003E\n\u003Cp\u003EThe default \u003Ccode\u003EBlazorWasm\u003C/code\u003E profile resolves to \u003Ccode\u003EHybrid\u003C/code\u003E in the current mode resolver.\u003C/p\u003E\n\u003Cp\u003EDo not manually force \u003Ccode\u003EPureOpaque\u003C/code\u003E for a standalone browser client simply because a Blazor Server application uses it.\u003C/p\u003E\n\u003Cp\u003EThe transport and authentication lifecycle must match the client environment.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-web-app-interactive-webassembly\u0022\u003E6. Blazor Web App \u2014 Interactive WebAssembly\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E A Blazor Web App with an ASP.NET Core host and a separate \u003Ccode\u003E.Client\u003C/code\u003E project.\u003C/p\u003E\n\u003Cp\u003EThis scenario differs from standalone WASM because components may participate in both server-side rendering and browser-side interactivity.\u003C/p\u003E\n\u003Cp\u003EThe current UltimateAuth sample registers the Blazor client integration in \u003Cstrong\u003Eboth projects\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EIt also adds a separate ASP.NET Core compatibility registration in the host.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022project-structure\u0022\u003EProject structure\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EMyApplication/\n\u251C\u2500\u2500 MyApplication/\n\u2502 \u251C\u2500\u2500 Program.cs\n\u2502 \u2514\u2500\u2500 Components/\n\u2502 \u2514\u2500\u2500 App.razor\n\u2502\n\u2514\u2500\u2500 MyApplication.Client/\n \u251C\u2500\u2500 Program.cs\n \u2514\u2500\u2500 Pages/\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe host serves the application.\u003C/p\u003E\n\u003Cp\u003EThe \u003Ccode\u003E.Client\u003C/code\u003E project contains browser-executed components and services.\u003C/p\u003E\n\u003Cp\u003EA remote UAuthHub manages authentication.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022host-project-packages\u0022\u003E6.1 Host project packages\u003C/h3\u003E\n\u003Cp\u003EInstall the Blazor client integration and ASP.NET Core compatibility package in the host:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\ndotnet add package CodeBeam.UltimateAuth.Client.AspNetCore\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022host-project-services\u0022\u003E6.2 Host project services\u003C/h3\u003E\n\u003Cp\u003EIn the host \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.AspNetCore;\nusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.Core.Domain;\n\nbuilder.Services\n .AddRazorComponents()\n .AddInteractiveWebAssemblyComponents();\n\nbuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;;\n options.Pkce.ReturnUrl = \u0026quot;https://localhost:6132/home\u0026quot;;\n options.Login.AllowCredentialPost = true;\n options.Reauth.Behavior = ReauthBehavior.RaiseEvent;\n});\n\nbuilder.Services.AddUltimateAuthAspNetCoreCompatibility();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EAddUltimateAuthAspNetCoreCompatibility()\u003C/code\u003E registers the compatibility authentication scheme and ASP.NET Core authorization services used by this hosting pattern.\u003C/p\u003E\n\u003Cp\u003EIt does \u003Cstrong\u003Enot\u003C/strong\u003E turn the host into an UltimateAuth authentication server.\u003C/p\u003E\n\u003Cp\u003EIt is also not equivalent to \u003Ccode\u003EAddUltimateAuthResourceApi()\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022host-project-pipeline\u0022\u003E6.3 Host project pipeline\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseHttpsRedirection();\n\napp.UseAntiforgery();\n\napp.MapStaticAssets();\n\napp.MapRazorComponents\u0026lt;App\u0026gt;()\n .AddInteractiveWebAssemblyRenderMode()\n .AddAdditionalAssemblies(typeof(MyApplication.Client._Imports).Assembly, UAuthAssemblies.BlazorClient().First());\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EReplace \u003Ccode\u003EMyApplication.Client._Imports\u003C/code\u003E with the corresponding type from your client project.\u003C/p\u003E\n\u003Cp\u003EThe additional assemblies allow the host to discover routable components from the WASM client project and UltimateAuth\u0027s Blazor client assembly.\u003C/p\u003E\n\u003Cp\u003EThe sample does not register \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E in this host because authentication is delegated to a separate UAuthHub.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-project-packages\u0022\u003E6.4 Client project packages\u003C/h3\u003E\n\u003Cp\u003EInstall the Blazor client adapter in the \u003Ccode\u003E.Client\u003C/code\u003E project:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-project-services\u0022\u003E6.5 Client project services\u003C/h3\u003E\n\u003Cp\u003EIn \u003Ccode\u003EMyApplication.Client/Program.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.Core.Domain;\n\nvar builder = WebAssemblyHostBuilder.CreateDefault(args);\n\nbuilder.Services.AddScoped(sp =\u0026gt; new HttpClient\n{\n BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)\n});\n\nbuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;;\n options.Pkce.ReturnUrl = \u0026quot;https://localhost:6132/home\u0026quot;;\n options.Login.AllowCredentialPost = true;\n options.Reauth.Behavior = ReauthBehavior.RaiseEvent;\n});\n\nawait builder.Build().RunAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe host and client configurations should agree on the remote UAuthHub URL and authentication flow settings.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022why-both-registrations-are-necessary\u0022\u003EWhy both registrations are necessary\u003C/h3\u003E\n\u003Cp\u003EThe host and browser client have separate dependency injection containers.\u003C/p\u003E\n\u003Cp\u003ERegistering \u003Ccode\u003EAddUltimateAuthClientBlazor()\u003C/code\u003E in the host does not automatically register those services inside the WebAssembly runtime.\u003C/p\u003E\n\u003Cp\u003ELikewise, registering the client adapter only in the \u003Ccode\u003E.Client\u003C/code\u003E project does not supply the services required during server-side integration.\u003C/p\u003E\n\u003Cp\u003EThe additional ASP.NET Core compatibility registration addresses the host\u0027s ASP.NET Core authentication and authorization integration requirements.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EImportant:\u003C/strong\u003E This compatibility layer is not a replacement for server-side authorization of your protected business APIs.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022resource-api-separate-protected-backend\u0022\u003E7. Resource API \u2014 Separate Protected Backend\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E An API that needs UltimateAuth authentication validation but does not host the authentication system itself.\u003C/p\u003E\n\u003Cp\u003EA Resource API can be an ASP.NET Core Web API, MVC application, or another supported server-side application.\u003C/p\u003E\n\u003Cp\u003EIt does not need to be a Blazor application.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022architecture\u0022\u003EArchitecture\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E Blazor Client\n \u2502\n \u250C\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2510\n \u25BC \u25BC\n UAuthHub Resource API\n \u25B2 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Authentication\n validation\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Resource API uses the configured UAuthHub for remote authentication and session validation behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022install-the-server-integration-package\u0022\u003EInstall the server integration package\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Server\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-services-1\u0022\u003EConfigure services\u003C/h3\u003E\n\u003Cp\u003EIn the Resource API\u0027s \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Server.Extensions;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.Services.AddControllers();\n\nbuilder.Services.AddUltimateAuthResourceApi(options =\u0026gt;\n{\n options.UAuthHubBaseUrl = \u0026quot;https://localhost:6112\u0026quot;;\n options.AllowedClientOrigins.Add(\u0026quot;https://localhost:6132\u0026quot;);\n options.CorsPolicyName = \u0026quot;resourceApi\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthHubBaseUrl\u003C/code\u003E is the base address of the authentication server.\u003C/p\u003E\n\u003Cp\u003ENotice that this example uses the Hub origin without the \u003Ccode\u003E/auth\u003C/code\u003E suffix, matching the current Resource API samples.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EAllowedClientOrigins\u003C/code\u003E controls the browser origins permitted by the Resource API\u0027s CORS configuration.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-pipeline\u0022\u003EConfigure the pipeline\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar app = builder.Build();\n\napp.UseHttpsRedirection();\n\napp.UseUltimateAuthResourceApiWithAspNetCore();\n\napp.MapControllers();\n\napp.Run();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EUseUltimateAuthResourceApiWithAspNetCore()\u003C/code\u003E installs the Resource API integration middleware together with ASP.NET Core authentication and authorization middleware.\u003C/p\u003E\n\u003Cp\u003EYou do not need to register a local UltimateAuth Server or its persistence bundle in the Resource API.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022protecting-endpoints\u0022\u003EProtecting endpoints\u003C/h3\u003E\n\u003Cp\u003EUse ASP.NET Core authorization attributes for endpoints that require authentication:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing Microsoft.AspNetCore.Authorization;\nusing Microsoft.AspNetCore.Mvc;\n\n[ApiController]\n[Route(\u0026quot;api/products\u0026quot;)]\npublic class ProductsController : ControllerBase\n{\n [HttpGet]\n [Authorize]\n public IActionResult GetProducts()\n {\n return Ok(new[]\n {\n new { Id = 1, Name = \u0026quot;Example product\u0026quot; }\n });\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe authorization attribute is enforced by ASP.NET Core on the server.\u003C/p\u003E\n\u003Cp\u003EFor permission-specific rules, configure and apply the corresponding authorization policies.\u003C/p\u003E\n\u003Cp\u003EDo not rely on Blazor UI authorization checks to protect Resource API endpoints.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022cors-configuration\u0022\u003ECORS configuration\u003C/h3\u003E\n\u003Cp\u003EThe current Resource API integration registers CORS from the configured allowed origins.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.AllowedClientOrigins.Add(\u0026quot;https://localhost:6132\u0026quot;);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EOnly add origins that should be allowed to call your API from a browser.\u003C/p\u003E\n\u003Cp\u003ECORS is a browser access control mechanism, not an authentication or authorization mechanism.\u003C/p\u003E\n\u003Cp\u003EA request must still pass the applicable authentication and authorization checks.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022resource-api-vs-uauthhub\u0022\u003EResource API vs UAuthHub\u003C/h3\u003E\n\u003Cp\u003EThese are different responsibilities.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EUAuthHub\u003C/th\u003E\n\u003Cth\u003EResource API\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EHosts authentication flows\u003C/td\u003E\n\u003Ctd\u003EHosts application resources\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EManages authentication sessions\u003C/td\u003E\n\u003Ctd\u003EValidates remote authentication context\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUses authentication persistence\u003C/td\u003E\n\u003Ctd\u003EDoes not require local authentication stores\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EProvides login and related endpoints\u003C/td\u003E\n\u003Ctd\u003EProvides application endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EServes authentication clients\u003C/td\u003E\n\u003Ctd\u003EServes authorized API consumers\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EUse a Resource API when you want independent deployment of business endpoints without duplicating the authentication server.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022resource-api-with-an-ef-core-backed-uauthhub\u0022\u003E8. Resource API with an EF Core-Backed UAuthHub\u003C/h2\u003E\n\u003Cp\u003EThe Resource API does not need to use EF Core just because UAuthHub does.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EInteractive WASM\n \u2502\n \u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25BA Resource API\n \u2502\n \u25BC\n UAuthHub\n \u2502\n \u25BC\n EF Core Database\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Hub stores users, credentials, sessions, and related security state in its database.\u003C/p\u003E\n\u003Cp\u003EThe Resource API communicates with the Hub through the configured remote validation integration.\u003C/p\u003E\n\u003Cp\u003EIts registration remains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthResourceApi(options =\u0026gt;\n{\n options.UAuthHubBaseUrl = \u0026quot;https://localhost:6112\u0026quot;;\n options.AllowedClientOrigins.Add(\u0026quot;https://localhost:6132\u0026quot;);\n options.CorsPolicyName = \u0026quot;resourceApi\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis separation allows the API and authentication infrastructure to be deployed, scaled, and maintained independently.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022calling-a-resource-api-from-blazor\u0022\u003E9. Calling a Resource API from Blazor\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth provides a unified Resource Client for accessing protected application APIs.\u003C/p\u003E\n\u003Cp\u003EInstead of configuring a separate HTTP client and implementing browser credential handling for ordinary resource requests, applications can use \u003Ccode\u003EIUAuthClient.Resource\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThis keeps authentication, identity operations, and application resource requests accessible through the same client SDK.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-resource-api\u0022\u003EConfigure the Resource API\u003C/h3\u003E\n\u003Cp\u003EFor a separate Resource API, configure its origin through the Blazor client registration:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client;\n\nbuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;;\n\n options.Resource.BaseAddress = \u0026quot;https://localhost:6122\u0026quot;;\n options.Resource.Credentials = UAuthResourceCredentialsMode.Auto;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EEndpoints.BasePath\u003C/code\u003E identifies the authentication server.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EResource.BaseAddress\u003C/code\u003E identifies the application Resource API.\u003C/p\u003E\n\u003Cp\u003EThese addresses can point to different applications.\u003C/p\u003E\n\u003Cp\u003EIf the Resource API shares the application\u0027s origin, \u003Ccode\u003EResource.BaseAddress\u003C/code\u003E can remain \u003Ccode\u003Enull\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022call-protected-resources\u0022\u003ECall Protected Resources\u003C/h3\u003E\n\u003Cp\u003EInject \u003Ccode\u003EIUAuthClient\u003C/code\u003E into a Blazor component:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@inject IUAuthClient UAuthClient\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThen retrieve application data:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Resource.GetAsync\u0026lt;List\u0026lt;Product\u0026gt;\u0026gt;(\u0026quot;/api/products\u0026quot;);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is not null)\n{\n products = result.Value;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Resource Client also supports POST, PUT, PATCH, and DELETE operations.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022application-specific-api-services\u0022\u003EApplication-Specific API Services\u003C/h3\u003E\n\u003Cp\u003EApplications can organize resource operations into small domain-specific services.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client;\nusing CodeBeam.UltimateAuth.Core.Contracts;\n\npublic sealed class ProductApiService(IUAuthClient client)\n{\n public Task\u0026lt;UAuthResult\u0026lt;List\u0026lt;Product\u0026gt;\u0026gt;\u0026gt; GetAllAsync()\n =\u0026gt; client.Resource.GetAsync\u0026lt;List\u0026lt;Product\u0026gt;\u0026gt;(\u0026quot;/api/products\u0026quot;);\n\n public Task\u0026lt;UAuthResult\u0026lt;Product\u0026gt;\u0026gt; CreateAsync(Product product)\n =\u0026gt; client.Resource.PostAsync\u0026lt;Product\u0026gt;(\u0026quot;/api/products\u0026quot;,product);\n\n public Task\u0026lt;UAuthResult\u0026gt; DeleteAsync(int id)\n =\u0026gt; client.Resource.DeleteAsync($\u0026quot;/api/products/{id}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERegister the application service:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddScoped\u0026lt;ProductApiService\u0026gt;();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe service uses UltimateAuth\u0027s transport and result contracts rather than implementing another HTTP abstraction.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022interactive-webassembly\u0022\u003EInteractive WebAssembly\u003C/h3\u003E\n\u003Cp\u003EIn an Interactive WebAssembly application, configure the UltimateAuth Blazor client in each project where the Resource Client will be used.\u003C/p\u003E\n\u003Cp\u003EThe host and \u003Ccode\u003E.Client\u003C/code\u003E project have separate dependency injection containers.\u003C/p\u003E\n\u003Cp\u003EA shared application API service may be registered in both projects when components execute in both environments.\u003C/p\u003E\n\u003Cp\u003EThe Resource Client uses browser-based transport, including when invoked from an interactive Blazor Server component.\u003C/p\u003E\n\u003Cp\u003EBrowser interop must be available before making resource requests. Do not initiate browser-dependent resource requests during static prerendering.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022standalone-webassembly\u0022\u003EStandalone WebAssembly\u003C/h3\u003E\n\u003Cp\u003EStandalone Blazor WebAssembly applications use the same Resource Client API.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;;\n options.Resource.BaseAddress = \u0026quot;https://localhost:6122\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EOnce configured, application components can call protected resources through \u003Ccode\u003EIUAuthClient.Resource\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EA separate application-specific \u003Ccode\u003EHttpClient\u003C/code\u003E is not required for these requests.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022browser-credentials-and-cors\u0022\u003EBrowser Credentials and CORS\u003C/h3\u003E\n\u003Cp\u003EFor cross-origin requests, the browser and receiving API must agree on credential handling.\u003C/p\u003E\n\u003Cp\u003EThe Resource Client supports \u003Ccode\u003EAuto\u003C/code\u003E, \u003Ccode\u003ESameOrigin\u003C/code\u003E, \u003Ccode\u003EInclude\u003C/code\u003E, and \u003Ccode\u003EOmit\u003C/code\u003E credential policies.\u003C/p\u003E\n\u003Cp\u003EFor cross-origin cookie-based authentication, configure the appropriate credential policy and ensure that the Resource API supports the required CORS and cookie settings.\u003C/p\u003E\n\u003Cp\u003EThe Resource Client does not automatically make an API authenticated merely because the user has signed in through UAuthHub.\u003C/p\u003E\n\u003Cp\u003EThe receiving API must validate the authentication material associated with the configured authentication mode and enforce its authorization policies.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-server-and-direct-service-calls\u0022\u003EBlazor Server and Direct Service Calls\u003C/h3\u003E\n\u003Cp\u003EBlazor Server applications can often access application services directly when those services are hosted in the same process.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar products = await productService.GetAllAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThere is no need to introduce an HTTP request solely to use the Resource Client.\u003C/p\u003E\n\u003Cp\u003EUse \u003Ccode\u003EIUAuthClient.Resource\u003C/code\u003E when the application actually needs to communicate with a protected Resource API through the browser.\u003C/p\u003E\n\u003Cp\u003EFor server-to-server requests, use an appropriate server-side HTTP client and authentication mechanism.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022learn-more\u0022\u003ELearn More\u003C/h3\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/client-sdk/resource-api-requests\u0022\u003EResource API Requests\u003C/a\u003E for supported operations, result handling, credential configuration, security considerations, and request cancellation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-modes-and-client-profiles\u0022\u003E10. Authentication Modes and Client Profiles\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth separates \u003Cstrong\u003Eclient profile detection\u003C/strong\u003E from \u003Cstrong\u003Eauthentication mode enforcement\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EThe default resolver maps client profiles to authentication modes.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EClient profile\u003C/th\u003E\n\u003Cth\u003EDefault resolved mode\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Server\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPureOpaque\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor WASM\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EWeb Server\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMAUI\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAPI\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPureJwt\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThis mapping describes the resolver\u0027s behavior, not a guarantee that every mode is currently fully supported by the server.\u003C/p\u003E\n\u003Cp\u003EIn particular, \u003Ccode\u003EPureJwt\u003C/code\u003E and \u003Ccode\u003ESemiHybrid\u003C/code\u003E have implementation limitations in the current release.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022restrict-allowed-modes\u0022\u003ERestrict allowed modes\u003C/h3\u003E\n\u003Cp\u003EOn the authentication server:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.AllowedModes =\n [\n UAuthMode.PureOpaque,\n UAuthMode.Hybrid\n ];\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EAllowedModes\u003C/code\u003E is a guardrail.\u003C/p\u003E\n\u003Cp\u003EIt does not select a mode for every request.\u003C/p\u003E\n\u003Cp\u003EThe effective mode is resolved from the client profile and then checked against the server\u0027s allowed-mode policy.\u003C/p\u003E\n\u003Cp\u003EFor most applications, use the default mode resolution unless you have a specific integration requirement.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-options-for-real-applications\u0022\u003E11. Server Options for Real Applications\u003C/h2\u003E\n\u003Cp\u003EProduction configuration should reflect your application\u0027s security requirements.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n options.Login.LockoutDuration = TimeSpan.FromMinutes(15);\n options.Identifiers.Behavior.AllowMultipleUsernames = true;\n options.UserProfile.EnableMultiProfile = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EServer options also compose the Core-defined login, session, token, PKCE, and multi-tenant options.\u003C/p\u003E\n\u003Cp\u003EConfigure those through \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EDo not independently configure Core options and assume the server will automatically use them.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuration-files\u0022\u003EConfiguration files\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth also supports configuration through \u003Ccode\u003Eappsettings.json\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;UltimateAuth\u0026quot;: {\n \u0026quot;Server\u0026quot;: {\n \u0026quot;Login\u0026quot;: {\n \u0026quot;MaxFailedAttempts\u0026quot;: 5,\n \u0026quot;LockoutDuration\u0026quot;: \u0026quot;00:15:00\u0026quot;\n }\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server options registration binds the \u003Ccode\u003EUltimateAuth:Server\u003C/code\u003E configuration section.\u003C/p\u003E\n\u003Cp\u003ERemember that effective mode defaults and mode-specific overrides are applied after the base options have been configured.\u003C/p\u003E\n\u003Cp\u003EFor detailed configuration behavior, see Server Options and Advanced Configuration.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-ip-address-reverse-proxy\u0022\u003E12. Client IP Address \u0026amp; Reverse Proxy\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth uses ASP.NET Core\u0027s resolved client IP address for device information and IP-based rate limiting. It does not directly trust \u003Ccode\u003EX-Forwarded-For\u003C/code\u003E or \u003Ccode\u003ECF-Connecting-IP\u003C/code\u003E headers.\u003C/p\u003E\n\u003Cp\u003EIf your application runs behind Caddy, Nginx, or another reverse proxy, configure ASP.NET Core to trust that proxy.\u003C/p\u003E\n\u003Cp\u003EIn \u003Ccode\u003EProgram.cs\u003C/code\u003E, before \u003Ccode\u003Ebuilder.Build()\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing System.Net;\nusing Microsoft.AspNetCore.HttpOverrides;\n\nbuilder.Services.Configure\u0026lt;ForwardedHeadersOptions\u0026gt;(options =\u0026gt;\n{\n options.ForwardedHeaders =\n ForwardedHeaders.XForwardedFor |\n ForwardedHeaders.XForwardedProto;\n\n // Replace with your actual Caddy or Nginx proxy IP.\n options.KnownProxies.Add(IPAddress.Parse(\u0026quot;172.20.0.5\u0026quot;));\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThen enable the middleware \u003Cstrong\u003Ebefore\u003C/strong\u003E UltimateAuth and HTTPS redirection:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseForwardedHeaders();\n\napp.UseHttpsRedirection();\napp.UseUltimateAuthWithAspNetCore();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe example IP \u003Ccode\u003E172.20.0.5\u003C/code\u003E represents a trusted reverse proxy, not the visitor\u0027s IP address. Replace it with your actual proxy IP. For Docker deployments, ensure the proxy address is stable or configure a narrowly scoped trusted network.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EImportant:\u003C/strong\u003E Never trust arbitrary forwarded headers or clear the trusted proxy lists to accept all sources. If your application connects directly to Kestrel without a reverse proxy, no forwarded headers configuration is required.\u003C/p\u003E\n\u003Cp\u003EFor multi-proxy deployments, configure the complete trusted proxy chain and forwarding behavior according to your infrastructure.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022development-and-production-differences\u0022\u003E13. Development and Production Differences\u003C/h2\u003E\n\u003Cp\u003EThe samples intentionally prioritize visibility and repeatability.\u003C/p\u003E\n\u003Cp\u003EA production application needs additional safeguards.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EArea\u003C/th\u003E\n\u003Cth\u003ESample or development\u003C/th\u003E\n\u003Cth\u003EProduction\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPersistence\u003C/td\u003E\n\u003Ctd\u003EInMemory or local SQLite\u003C/td\u003E\n\u003Ctd\u003EDurable database\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESeed accounts\u003C/td\u003E\n\u003Ctd\u003EExample users and passwords\u003C/td\u003E\n\u003Ctd\u003EReal provisioning\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EDatabase initialization\u003C/td\u003E\n\u003Ctd\u003EMay reset sample data\u003C/td\u003E\n\u003Ctd\u003EReviewed migrations\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EHTTPS\u003C/td\u003E\n\u003Ctd\u003ELocal development certificate\u003C/td\u003E\n\u003Ctd\u003EProper TLS termination\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECORS\u003C/td\u003E\n\u003Ctd\u003ELocalhost origins\u003C/td\u003E\n\u003Ctd\u003EExact trusted origins\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESigning keys\u003C/td\u003E\n\u003Ctd\u003EDevelopment defaults\u003C/td\u003E\n\u003Ctd\u003EManaged, persistent keys\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EDiagnostics\u003C/td\u003E\n\u003Ctd\u003EDetailed debugging\u003C/td\u003E\n\u003Ctd\u003ERestrict sensitive diagnostics\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECredential recovery\u003C/td\u003E\n\u003Ctd\u003EDevelopment notifier\u003C/td\u003E\n\u003Ctd\u003EReal secure notification channel\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EIdentifier verification\u003C/td\u003E\n\u003Ctd\u003EDevelopment bypass\u003C/td\u003E\n\u003Ctd\u003EProof-based verification\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESecrets\u003C/td\u003E\n\u003Ctd\u003ELocal settings\u003C/td\u003E\n\u003Ctd\u003ESecure configuration provider\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe sample projects include development-only implementations for credential reset notifications and identifier verification.\u003C/p\u003E\n\u003Cp\u003EThose implementations demonstrate extension points and should not be treated as production verification systems.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-recovery\u0022\u003ECredential recovery\u003C/h3\u003E\n\u003Cp\u003EIf your application exposes credential recovery, configure a real implementation of the credential reset notifier.\u003C/p\u003E\n\u003Cp\u003ERecovery secrets must be delivered through an appropriate channel and must not be exposed directly through anonymous API responses.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-verification\u0022\u003EIdentifier verification\u003C/h3\u003E\n\u003Cp\u003EIf your application supports verified email addresses, phone numbers, or other identifiers, use an implementation that verifies ownership through suitable proof.\u003C/p\u003E\n\u003Cp\u003EDo not use a development bypass verifier in production.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022signing-keys\u0022\u003ESigning keys\u003C/h3\u003E\n\u003Cp\u003EConfigure persistent signing keys for authentication modes that issue JWTs.\u003C/p\u003E\n\u003Cp\u003EDevelopment signing key providers are not a substitute for production key management.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022deployment-checklist\u0022\u003E14. Deployment Checklist\u003C/h2\u003E\n\u003Cp\u003EBefore deploying, verify the following:\u003C/p\u003E\n\u003Cul class=\u0022contains-task-list\u0022\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E The selected architecture matches the actual client hosting model.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E The authentication server uses appropriate persistent storage.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Database migrations have been reviewed and applied.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Sample users and development-only verification services are disabled.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Production signing keys and secrets are configured.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E HTTPS is enforced at the appropriate boundary.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E UAuthHub and Resource API origins are explicitly configured.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Authentication endpoints and application endpoints are protected appropriately.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Client and server endpoint URLs match the deployment.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Session expiration, refresh, logout, and revocation have been tested.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Protected resources enforce server-side authorization.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Reverse proxy headers are configured correctly if applicable.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Authentication state and persistence survive the expected application lifecycle.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-your-architecture\u0022\u003E15. Choosing Your Architecture\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EStart with one server and one client. Connect more clients whenever you need them.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EUltimateAuth is built around two fundamental responsibilities:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EUltimateAuth Server\u003C/strong\u003E \u2014 Manages authentication, users, credentials, sessions, authorization, and the security lifecycle.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EUltimateAuth Client\u003C/strong\u003E \u2014 Integrates an application with the authentication system using the appropriate client profile and authentication flows.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EEvery application needs access to an authentication server, but that server does not have to be a separate application. It can be hosted within your existing ASP.NET Core application or deployed independently as UAuthHub.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022single-application-the-simplest-setup\u0022\u003ESingle Application \u2014 The Simplest Setup\u003C/h3\u003E\n\u003Cp\u003EFor a traditional ASP.NET Core or Blazor Server application, UltimateAuth Server and Client can run together.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Your Application \u2502\n\u2502 \u2502\n\u2502 UltimateAuth Client \u2502\n\u2502 \u2502 \u2502\n\u2502 UltimateAuth Server \u2502\n\u2502 \u2502 \u2502\n\u2502 Database \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis approach is ideal when you want authentication without introducing another service or deployment.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022multiple-applications-one-authentication-server\u0022\u003EMultiple Applications \u2014 One Authentication Server\u003C/h3\u003E\n\u003Cp\u003EAs your system grows, you can connect multiple client applications to the same UltimateAuth Server.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E Blazor Server Blazor WASM\n \u2502 \u2502\n \u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n \u2502 \u2502 \u2502\n Razor Pages MAUI Other .NET Clients\n \u2502 \u2502 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2502\n \u25BC\n UltimateAuth Server\n UAuthHub\n \u2502\n \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u25BC \u25BC \u25BC\n Users Sessions Authorization\n \u2502\n \u25BC\n Persistence\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EEach client can have its own application structure, hosting model, and authentication requirements while sharing the same authentication authority.\u003C/p\u003E\n\u003Cp\u003EFor example, a business platform might use Blazor Server for administration, Blazor WASM for its customer portal, MAUI for mobile access, and Razor Pages for an internal application.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EAdding another client does not require replacing the existing clients or creating another UltimateAuth Server.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThe same users, credentials, security policies, and session infrastructure can serve multiple applications, subject to the authentication modes and client integrations supported by the installed version.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022distributed-applications-separate-resource-apis\u0022\u003EDistributed Applications \u2014 Separate Resource APIs\u003C/h3\u003E\n\u003Cp\u003EWhen your architecture includes independently deployed backend services, Resource APIs can integrate with the same authentication authority.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E Multiple Client Applications\n \u2502\n \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u25BC \u25BC\n UAuthHub Resource APIs\n \u25B2 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Authentication\n Validation\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EResource APIs protect application resources without becoming additional authentication servers.\u003C/p\u003E\n\u003Cp\u003EThis lets you separate business services from authentication infrastructure while maintaining a shared security model.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022one-foundation-different-scales\u0022\u003EOne Foundation, Different Scales\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EYour scenario\u003C/th\u003E\n\u003Cth\u003ERecommended approach\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EA simple Blazor Server application\u003C/td\u003E\n\u003Ctd\u003EIntegrated Server \u002B Client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EA traditional ASP.NET Core application\u003C/td\u003E\n\u003Ctd\u003EServer hosted within the application\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EA standalone Blazor WASM application\u003C/td\u003E\n\u003Ctd\u003EClient \u002B remote UAuthHub\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAn Interactive WASM application\u003C/td\u003E\n\u003Ctd\u003EHost \u002B WASM Client \u002B remote UAuthHub\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMultiple web, desktop, or mobile applications\u003C/td\u003E\n\u003Ctd\u003EMultiple Clients \u002B shared UAuthHub\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMultiple backend services\u003C/td\u003E\n\u003Ctd\u003EShared UAuthHub \u002B Resource APIs\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EUltimateAuth does not require a complex architecture to get started, and it does not restrict you to a single client as your application grows.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EStart with the simplest deployment that meets your needs. Add new client applications, persistent storage, or separate Resource APIs as your requirements evolve.\u003C/p\u003E\n\u003Cp\u003EThe authentication foundation remains consistent.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/getting-started/quickstart\u0022\u003EQuickStart\u003C/a\u003E \u2014 Build the minimal in-memory application.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/getting-started/ecosystem\u0022\u003EEcosystem\u003C/a\u003E \u2014 Explore packages and supported integrations.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/server-options\u0022\u003EServer Options\u003C/a\u003E \u2014 Configure server authentication and security behavior.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/client-options\u0022\u003EClient Options\u003C/a\u003E \u2014 Configure client profiles, endpoints, refresh, and reauthentication.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E \u2014 Explore effective options, dependency injection, and extension points.\u003C/li\u003E\n\u003C/ul\u003E\n", + "Html": "\n\u003Cp\u003E\u003Cstrong\u003EOne authentication framework. Different deployment models.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThe \u003Ca href=\u0022/docs/getting-started/quickstart\u0022\u003EQuickStart\u003C/a\u003E demonstrates UltimateAuth with Blazor Server and in-memory storage.\u003C/p\u003E\n\u003Cp\u003EReal applications may require persistent data, a separate authentication server, browser-based clients, or independently deployed APIs.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth supports these scenarios through different service registrations and hosting integrations. You can choose the architecture that fits your application without configuring every available component.\u003C/p\u003E\n\u003Cp\u003EThis guide covers the setup patterns demonstrated by the UltimateAuth samples.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022choose-your-architecture\u0022\u003E1. Choose Your Architecture\u003C/h2\u003E\n\u003Cp\u003EStart by identifying where authentication is hosted and how your application communicates with it.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EApplication architecture\u003C/th\u003E\n\u003Cth\u003EAuthentication host\u003C/th\u003E\n\u003Cth\u003EApplication registration\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Server, single project\u003C/td\u003E\n\u003Ctd\u003ESame application\u003C/td\u003E\n\u003Ctd\u003EServer \u002B persistence \u002B Blazor client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Server with EF Core\u003C/td\u003E\n\u003Ctd\u003ESame application\u003C/td\u003E\n\u003Ctd\u003EServer \u002B EF Core \u002B Blazor client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EDedicated UAuthHub\u003C/td\u003E\n\u003Ctd\u003ESeparate authentication application\u003C/td\u003E\n\u003Ctd\u003EServer \u002B persistence \u002B Hub\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EStandalone Blazor WASM\u003C/td\u003E\n\u003Ctd\u003ERemote UAuthHub\u003C/td\u003E\n\u003Ctd\u003EBlazor client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Web App, Interactive WASM\u003C/td\u003E\n\u003Ctd\u003ERemote UAuthHub\u003C/td\u003E\n\u003Ctd\u003EHost integration \u002B WASM client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESeparate Resource API\u003C/td\u003E\n\u003Ctd\u003ERemote UAuthHub\u003C/td\u003E\n\u003Ctd\u003EResource API integration\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese models can be combined.\u003C/p\u003E\n\u003Cp\u003EFor example, an application might use an EF Core-backed UAuthHub, a standalone Blazor WASM frontend, and a separate Resource API.\u003C/p\u003E\n\u003Cp\u003EThe frontend handles user interaction, UAuthHub manages authentication, and the Resource API enforces access to protected resources.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022persistent-storage-with-entity-framework-core\u0022\u003E2. Persistent Storage with Entity Framework Core\u003C/h2\u003E\n\u003Cp\u003EFor applications that need authentication state to survive restarts, use a persistent storage provider.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth provides an Entity Framework Core bundle that registers its reference domain implementations and EF Core-backed stores.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022install-packages\u0022\u003EInstall packages\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle\ndotnet add package Microsoft.EntityFrameworkCore.Sqlite\ndotnet add package Microsoft.EntityFrameworkCore.Design\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe SQLite provider is used here for simplicity. You can use another compatible EF Core database provider.\u003C/p\u003E\n\u003Cp\u003EWhen using the EF Core bundle, you do not need the in-memory bundle.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022register-the-services\u0022\u003ERegister the services\u003C/h3\u003E\n\u003Cp\u003EIn \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.EntityFrameworkCore;\nusing CodeBeam.UltimateAuth.Server.Extensions;\nusing Microsoft.EntityFrameworkCore;\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(\u0026quot;Data Source=uauth.db\u0026quot;, sqlite =\u0026gt; sqlite.MigrationsAssembly(typeof(Program).Assembly.GetName().Name!));\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EAddUltimateAuthEntityFrameworkCore()\u003C/code\u003E registers the EF Core stores and reference domain implementations.\u003C/p\u003E\n\u003Cp\u003EThe bundle provides a unified \u003Ccode\u003EUAuthDbContext\u003C/code\u003E for the supported authentication domains.\u003C/p\u003E\n\u003Cp\u003EYou do not need to register each user, session, credential, token, or authorization store individually.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-database-connection\u0022\u003EConfigure the database connection\u003C/h3\u003E\n\u003Cp\u003EFor a deployed application, prefer configuration over hardcoded connection strings.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003Eappsettings.json\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;ConnectionStrings\u0026quot;: {\n \u0026quot;UltimateAuth\u0026quot;: \u0026quot;Data Source=uauth.db\u0026quot;\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar connectionString = builder.Configuration.GetConnectionString(\u0026quot;UltimateAuth\u0026quot;)\n ?? throw new InvalidOperationException(\u0026quot;UltimateAuth connection string is missing.\u0026quot;);\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(connectionString);\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor production databases, use your deployment environment\u0027s secret management facilities rather than storing credentials in source control.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022database-migrations\u0022\u003EDatabase migrations\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth does not automatically create or apply migrations when the EF Core bundle is registered.\u003C/p\u003E\n\u003Cp\u003EYour application owns the database migration lifecycle.\u003C/p\u003E\n\u003Cp\u003EInstall the EF Core CLI if necessary:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet tool install --global dotnet-ef\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ECreate the initial migration:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet ef migrations add InitUAuth --context UAuthDbContext\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EApply it:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet ef database update --context UAuthDbContext\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor Visual Studio Package Manager Console:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EPOWERSHELL\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-powershell\u0022\u003EAdd-Migration InitUAuth -Context UAuthDbContext\nUpdate-Database -Context UAuthDbContext\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EInstall \u003Ccode\u003EMicrosoft.EntityFrameworkCore.Tools\u003C/code\u003E if required.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022applying-migrations-programmatically\u0022\u003EApplying migrations programmatically\u003C/h3\u003E\n\u003Cp\u003EThe EF Core samples demonstrate initialization through a scoped \u003Ccode\u003EUAuthDbContext\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor a controlled development environment:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.EntityFrameworkCore;\nusing Microsoft.EntityFrameworkCore;\n\nusing (var scope = app.Services.CreateScope())\n{\n var db = scope.ServiceProvider.GetRequiredService\u0026lt;UAuthDbContext\u0026gt;();\n await db.Database.MigrateAsync();\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis applies pending migrations. It does not generate migration files.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EProduction guidance:\u003C/strong\u003E Prefer applying reviewed migrations as a separate deployment step, particularly when multiple application instances share a database.\u003C/p\u003E\n\u003Cp\u003EDo not copy the sample initializer\u0027s \u003Ccode\u003Ereset: true\u003C/code\u003E behavior into production. The samples use database reset functionality for repeatable demonstrations, not as a safe deployment strategy.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022optional-sample-accounts-with-ef-core\u0022\u003EOptional: sample accounts with EF Core\u003C/h3\u003E\n\u003Cp\u003EThe sample seed package can also work with EF Core persistence.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Sample.Seed\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERegister the scoped seed services:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddScopedUltimateAuthSampleSeed();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAfter preparing the database:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (app.Environment.IsDevelopment())\n{\n await app.SeedUltimateAuthAsync();\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe difference matters:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EAddUltimateAuthSampleSeed()\u003C/code\u003E is used by the in-memory samples.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EAddScopedUltimateAuthSampleSeed()\u003C/code\u003E is used by the EF Core samples.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EDo not deploy the demonstration accounts and passwords to production.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-server-integrated-authentication\u0022\u003E3. Blazor Server \u2014 Integrated Authentication\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E A Blazor Server application that wants to host its own authentication system.\u003C/p\u003E\n\u003Cp\u003EThis is the simplest deployment model for a server-side Blazor application.\u003C/p\u003E\n\u003Cp\u003EThe application hosts UltimateAuth Server and uses the Blazor client adapter in the same project.\u003C/p\u003E\n\u003Cp\u003EA separate UAuthHub is not required.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022packages\u0022\u003EPackages\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\ndotnet add package CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle\ndotnet add package Microsoft.EntityFrameworkCore.Sqlite\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022services\u0022\u003EServices\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.EntityFrameworkCore;\nusing CodeBeam.UltimateAuth.Server.Extensions;\nusing Microsoft.EntityFrameworkCore;\n\nbuilder.Services\n .AddRazorComponents()\n .AddInteractiveServerComponents();\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(\u0026quot;Data Source=uauth.db\u0026quot;);\n });\n\nbuilder.Services.AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022pipeline\u0022\u003EPipeline\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseHttpsRedirection();\napp.UseStaticFiles();\n\napp.UseUltimateAuthWithAspNetCore();\napp.UseAntiforgery();\n\napp.MapUltimateAuthEndpoints();\n\napp.MapRazorComponents\u0026lt;App\u0026gt;()\n .AddInteractiveServerRenderMode()\n .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient());\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAlso configure the \u003Ccode\u003EUAuthApp\u003C/code\u003E root component, login page, and UltimateAuth JavaScript file as demonstrated in QuickStart.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-behavior\u0022\u003EAuthentication behavior\u003C/h3\u003E\n\u003Cp\u003EThe default client profile resolution selects \u003Ccode\u003EPureOpaque\u003C/code\u003E for Blazor Server.\u003C/p\u003E\n\u003Cp\u003EThis uses session-oriented authentication rather than requiring the application to manage access and refresh tokens manually.\u003C/p\u003E\n\u003Cp\u003EYou can configure session lifetimes, login policies, credential policies, and other behavior through \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n options.Login.LockoutDuration = TimeSpan.FromMinutes(15);\n options.Session.Lifetime = TimeSpan.FromDays(7);\n options.Identifiers.Behavior.AllowMultipleUsernames = true;\n options.Password.Level = PasswordPolicyLevel.Strict;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe effective authentication mode is resolved at runtime. The application does not need to select a single mode globally.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022password-security-configuration\u0022\u003EPassword Security Configuration\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth uses the Standard password policy by default. Applications can select Basic or Strict, or override individual requirements through \u003Ccode\u003Eoptions.Password\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor the preset comparison, configuration examples, and password-history settings, see \u003Ca href=\u0022/docs/security/password-policy\u0022\u003EPassword Policy \u0026amp; History\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022uauthhub-dedicated-authentication-server\u0022\u003E4. UAuthHub \u2014 Dedicated Authentication Server\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E Applications where multiple clients share one authentication authority.\u003C/p\u003E\n\u003Cp\u003EUAuthHub hosts UltimateAuth\u0027s authentication services separately from your frontend applications.\u003C/p\u003E\n\u003Cp\u003EIt is particularly relevant for standalone WASM applications, Interactive WASM applications, and systems with separate Resource APIs.\u003C/p\u003E\n\u003Cp\u003EA typical architecture looks like this:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E Standalone WASM Interactive WASM\n \u2502 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2502\n \u25BC\n UAuthHub\n Authentication\n Sessions \u0026amp; Users\n \u2502\n \u25BC\n Persistence\n\n Resource API \u2500\u2500\u2500\u25BA UAuthHub\n (validation)\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022create-the-hub-application\u0022\u003ECreate the Hub application\u003C/h3\u003E\n\u003Cp\u003EA UAuthHub can be hosted in an ASP.NET Core Blazor Server application.\u003C/p\u003E\n\u003Cp\u003EThe current samples use Blazor Server to provide the Hub\u0027s login UI.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022in-memory-uauthhub\u0022\u003EIn-memory UAuthHub\u003C/h3\u003E\n\u003Cp\u003EFor development:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\ndotnet add package CodeBeam.UltimateAuth.InMemory.Bundle\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERegister:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.InMemory;\nusing CodeBeam.UltimateAuth.Server.Extensions;\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthInMemory()\n .AddUAuthHub(options =\u0026gt;\n {\n options.AllowedClientOrigins.Add(\u0026quot;https://localhost:6130\u0026quot;);\n });\n\nbuilder.Services.AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Hub\u0027s Blazor client registration is needed when the Hub itself hosts UltimateAuth-powered Blazor pages and login UI.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022ef-core-uauthhub\u0022\u003EEF Core UAuthHub\u003C/h3\u003E\n\u003Cp\u003EFor persistent storage:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.EntityFrameworkCore;\nusing Microsoft.EntityFrameworkCore;\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(\u0026quot;Data Source=uauthhub.db\u0026quot;);\n })\n .AddUAuthHub(options =\u0026gt;\n {\n options.AllowedClientOrigins.Add(\u0026quot;https://localhost:6132\u0026quot;);\n });\n\nbuilder.Services.AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe \u003Ccode\u003EAllowedClientOrigins\u003C/code\u003E collection identifies browser origins permitted to interact with the Hub.\u003C/p\u003E\n\u003Cp\u003EUse exact origins, including scheme and port. Do not include paths such as \u003Ccode\u003E/home\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022hub-pipeline\u0022\u003EHub pipeline\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseHttpsRedirection();\n\napp.UseUltimateAuthWithAspNetCore();\napp.UseAntiforgery();\n\napp.MapUltimateAuthEndpoints();\napp.MapUAuthHub();\n\napp.MapStaticAssets();\n\napp.MapRazorComponents\u0026lt;App\u0026gt;()\n .AddInteractiveServerRenderMode()\n .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient());\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EMapUltimateAuthEndpoints()\u003C/code\u003E maps the authentication endpoints.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EMapUAuthHub()\u003C/code\u003E maps the Hub-specific entry endpoint.\u003C/p\u003E\n\u003Cp\u003EBoth registrations are used by the current UAuthHub samples.\u003C/p\u003E\n\u003Cp\u003EThe Hub also needs its login page and Blazor application lifecycle components.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-origins\u0022\u003EChoosing origins\u003C/h3\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EApplication\u003C/th\u003E\n\u003Cth\u003EURL\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUAuthHub\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ehttps://localhost:6112\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EStandalone WASM\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ehttps://localhost:6130\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EInteractive WASM\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ehttps://localhost:6132\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EConfigure the Hub\u0027s allowed origins for the actual frontend applications it serves.\u003C/p\u003E\n\u003Cp\u003EThese localhost addresses are illustrative and must match your deployment.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-standalone-webassembly\u0022\u003E5. Blazor Standalone WebAssembly\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E A browser-hosted application with a separate UAuthHub.\u003C/p\u003E\n\u003Cp\u003EStandalone Blazor WASM runs in the browser. It does not host UltimateAuth Server or database persistence.\u003C/p\u003E\n\u003Cp\u003EAuthentication is handled by the remote UAuthHub.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-packages\u0022\u003EClient packages\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-services\u0022\u003EConfigure services\u003C/h3\u003E\n\u003Cp\u003EIn the standalone WASM application\u0027s \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.Core.Domain;\n\nvar builder = WebAssemblyHostBuilder.CreateDefault(args);\n\nbuilder.RootComponents.Add\u0026lt;App\u0026gt;(\u0026quot;#app\u0026quot;);\nbuilder.RootComponents.Add\u0026lt;HeadOutlet\u0026gt;(\u0026quot;head::after\u0026quot;);\n\nbuilder.Services.AddScoped(sp =\u0026gt; new HttpClient\n{\n BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)\n});\n\nbuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6110/auth\u0026quot;;\n options.Pkce.ReturnUrl = \u0026quot;https://localhost:6130/home\u0026quot;;\n options.Login.AllowCredentialPost = true;\n options.Reauth.Behavior = ReauthBehavior.RaiseEvent;\n});\n\nawait builder.Build().RunAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe current standalone WASM sample explicitly configures \u003Ccode\u003ELogin.AllowCredentialPost\u003C/code\u003E and the PKCE return URL.\u003C/p\u003E\n\u003Cp\u003EThese are client-side settings. The UAuthHub must be configured independently.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022what-the-urls-mean\u0022\u003EWhat the URLs mean\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EEndpoints.BasePath\u003C/code\u003E points to the remote authentication endpoints.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EPkce.ReturnUrl\u003C/code\u003E identifies the frontend destination used by the relevant PKCE flow.\u003C/p\u003E\n\u003Cp\u003EThe Hub must recognize the frontend\u0027s origin, and any return URL used in an authentication flow must be validated against the application\u0027s security policy.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022root-component\u0022\u003ERoot component\u003C/h3\u003E\n\u003Cp\u003EA standalone WASM application should initialize UltimateAuth through \u003Ccode\u003EUAuthApp\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor example, its root application or routing component can contain:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client.Blazor\n\n\u0026lt;UAuthApp UseBuiltInRouter=\u0026quot;true\u0026quot; AppAssembly=\u0026quot;typeof(Program).Assembly\u0026quot; DefaultLayout=\u0026quot;typeof(Layout.MainLayout)\u0026quot; /\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EInclude the UltimateAuth JavaScript asset in the application\u0027s HTML host page.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-modes\u0022\u003EAuthentication modes\u003C/h3\u003E\n\u003Cp\u003EThe default \u003Ccode\u003EBlazorWasm\u003C/code\u003E profile resolves to \u003Ccode\u003EHybrid\u003C/code\u003E in the current mode resolver.\u003C/p\u003E\n\u003Cp\u003EDo not manually force \u003Ccode\u003EPureOpaque\u003C/code\u003E for a standalone browser client simply because a Blazor Server application uses it.\u003C/p\u003E\n\u003Cp\u003EThe transport and authentication lifecycle must match the client environment.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-web-app-interactive-webassembly\u0022\u003E6. Blazor Web App \u2014 Interactive WebAssembly\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E A Blazor Web App with an ASP.NET Core host and a separate \u003Ccode\u003E.Client\u003C/code\u003E project.\u003C/p\u003E\n\u003Cp\u003EThis scenario differs from standalone WASM because components may participate in both server-side rendering and browser-side interactivity.\u003C/p\u003E\n\u003Cp\u003EThe current UltimateAuth sample registers the Blazor client integration in \u003Cstrong\u003Eboth projects\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EIt also adds a separate ASP.NET Core compatibility registration in the host.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022project-structure\u0022\u003EProject structure\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EMyApplication/\n\u251C\u2500\u2500 MyApplication/\n\u2502 \u251C\u2500\u2500 Program.cs\n\u2502 \u2514\u2500\u2500 Components/\n\u2502 \u2514\u2500\u2500 App.razor\n\u2502\n\u2514\u2500\u2500 MyApplication.Client/\n \u251C\u2500\u2500 Program.cs\n \u2514\u2500\u2500 Pages/\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe host serves the application.\u003C/p\u003E\n\u003Cp\u003EThe \u003Ccode\u003E.Client\u003C/code\u003E project contains browser-executed components and services.\u003C/p\u003E\n\u003Cp\u003EA remote UAuthHub manages authentication.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022host-project-packages\u0022\u003E6.1 Host project packages\u003C/h3\u003E\n\u003Cp\u003EInstall the Blazor client integration and ASP.NET Core compatibility package in the host:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\ndotnet add package CodeBeam.UltimateAuth.Client.AspNetCore\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022host-project-services\u0022\u003E6.2 Host project services\u003C/h3\u003E\n\u003Cp\u003EIn the host \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.AspNetCore;\nusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.Core.Domain;\n\nbuilder.Services\n .AddRazorComponents()\n .AddInteractiveWebAssemblyComponents();\n\nbuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;;\n options.Pkce.ReturnUrl = \u0026quot;https://localhost:6132/home\u0026quot;;\n options.Login.AllowCredentialPost = true;\n options.Reauth.Behavior = ReauthBehavior.RaiseEvent;\n});\n\nbuilder.Services.AddUltimateAuthAspNetCoreCompatibility();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EAddUltimateAuthAspNetCoreCompatibility()\u003C/code\u003E registers the compatibility authentication scheme and ASP.NET Core authorization services used by this hosting pattern.\u003C/p\u003E\n\u003Cp\u003EIt does \u003Cstrong\u003Enot\u003C/strong\u003E turn the host into an UltimateAuth authentication server.\u003C/p\u003E\n\u003Cp\u003EIt is also not equivalent to \u003Ccode\u003EAddUltimateAuthResourceApi()\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022host-project-pipeline\u0022\u003E6.3 Host project pipeline\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseHttpsRedirection();\n\napp.UseAntiforgery();\n\napp.MapStaticAssets();\n\napp.MapRazorComponents\u0026lt;App\u0026gt;()\n .AddInteractiveWebAssemblyRenderMode()\n .AddAdditionalAssemblies(typeof(MyApplication.Client._Imports).Assembly, UAuthAssemblies.BlazorClient().First());\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EReplace \u003Ccode\u003EMyApplication.Client._Imports\u003C/code\u003E with the corresponding type from your client project.\u003C/p\u003E\n\u003Cp\u003EThe additional assemblies allow the host to discover routable components from the WASM client project and UltimateAuth\u0027s Blazor client assembly.\u003C/p\u003E\n\u003Cp\u003EThe sample does not register \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E in this host because authentication is delegated to a separate UAuthHub.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-project-packages\u0022\u003E6.4 Client project packages\u003C/h3\u003E\n\u003Cp\u003EInstall the Blazor client adapter in the \u003Ccode\u003E.Client\u003C/code\u003E project:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-project-services\u0022\u003E6.5 Client project services\u003C/h3\u003E\n\u003Cp\u003EIn \u003Ccode\u003EMyApplication.Client/Program.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.Core.Domain;\n\nvar builder = WebAssemblyHostBuilder.CreateDefault(args);\n\nbuilder.Services.AddScoped(sp =\u0026gt; new HttpClient\n{\n BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)\n});\n\nbuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;;\n options.Pkce.ReturnUrl = \u0026quot;https://localhost:6132/home\u0026quot;;\n options.Login.AllowCredentialPost = true;\n options.Reauth.Behavior = ReauthBehavior.RaiseEvent;\n});\n\nawait builder.Build().RunAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe host and client configurations should agree on the remote UAuthHub URL and authentication flow settings.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022why-both-registrations-are-necessary\u0022\u003EWhy both registrations are necessary\u003C/h3\u003E\n\u003Cp\u003EThe host and browser client have separate dependency injection containers.\u003C/p\u003E\n\u003Cp\u003ERegistering \u003Ccode\u003EAddUltimateAuthClientBlazor()\u003C/code\u003E in the host does not automatically register those services inside the WebAssembly runtime.\u003C/p\u003E\n\u003Cp\u003ELikewise, registering the client adapter only in the \u003Ccode\u003E.Client\u003C/code\u003E project does not supply the services required during server-side integration.\u003C/p\u003E\n\u003Cp\u003EThe additional ASP.NET Core compatibility registration addresses the host\u0027s ASP.NET Core authentication and authorization integration requirements.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EImportant:\u003C/strong\u003E This compatibility layer is not a replacement for server-side authorization of your protected business APIs.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022resource-api-separate-protected-backend\u0022\u003E7. Resource API \u2014 Separate Protected Backend\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E An API that needs UltimateAuth authentication validation but does not host the authentication system itself.\u003C/p\u003E\n\u003Cp\u003EA Resource API can be an ASP.NET Core Web API, MVC application, or another supported server-side application.\u003C/p\u003E\n\u003Cp\u003EIt does not need to be a Blazor application.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022architecture\u0022\u003EArchitecture\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E Blazor Client\n \u2502\n \u250C\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2510\n \u25BC \u25BC\n UAuthHub Resource API\n \u25B2 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Authentication\n validation\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Resource API uses the configured UAuthHub for remote authentication and session validation behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022install-the-server-integration-package\u0022\u003EInstall the server integration package\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Server\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-services-1\u0022\u003EConfigure services\u003C/h3\u003E\n\u003Cp\u003EIn the Resource API\u0027s \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Server.Extensions;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.Services.AddControllers();\n\nbuilder.Services.AddUltimateAuthResourceApi(options =\u0026gt;\n{\n options.UAuthHubBaseUrl = \u0026quot;https://localhost:6112\u0026quot;;\n options.AllowedClientOrigins.Add(\u0026quot;https://localhost:6132\u0026quot;);\n options.CorsPolicyName = \u0026quot;resourceApi\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthHubBaseUrl\u003C/code\u003E is the base address of the authentication server.\u003C/p\u003E\n\u003Cp\u003ENotice that this example uses the Hub origin without the \u003Ccode\u003E/auth\u003C/code\u003E suffix, matching the current Resource API samples.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EAllowedClientOrigins\u003C/code\u003E controls the browser origins permitted by the Resource API\u0027s CORS configuration.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-pipeline\u0022\u003EConfigure the pipeline\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar app = builder.Build();\n\napp.UseHttpsRedirection();\n\napp.UseUltimateAuthResourceApiWithAspNetCore();\n\napp.MapControllers();\n\napp.Run();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EUseUltimateAuthResourceApiWithAspNetCore()\u003C/code\u003E installs the Resource API integration middleware together with ASP.NET Core authentication and authorization middleware.\u003C/p\u003E\n\u003Cp\u003EYou do not need to register a local UltimateAuth Server or its persistence bundle in the Resource API.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022protecting-endpoints\u0022\u003EProtecting endpoints\u003C/h3\u003E\n\u003Cp\u003EUse ASP.NET Core authorization attributes for endpoints that require authentication:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing Microsoft.AspNetCore.Authorization;\nusing Microsoft.AspNetCore.Mvc;\n\n[ApiController]\n[Route(\u0026quot;api/products\u0026quot;)]\npublic class ProductsController : ControllerBase\n{\n [HttpGet]\n [Authorize]\n public IActionResult GetProducts()\n {\n return Ok(new[]\n {\n new { Id = 1, Name = \u0026quot;Example product\u0026quot; }\n });\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe authorization attribute is enforced by ASP.NET Core on the server.\u003C/p\u003E\n\u003Cp\u003EFor permission-specific rules, configure and apply the corresponding authorization policies.\u003C/p\u003E\n\u003Cp\u003EDo not rely on Blazor UI authorization checks to protect Resource API endpoints.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022cors-configuration\u0022\u003ECORS configuration\u003C/h3\u003E\n\u003Cp\u003EThe current Resource API integration registers CORS from the configured allowed origins.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.AllowedClientOrigins.Add(\u0026quot;https://localhost:6132\u0026quot;);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EOnly add origins that should be allowed to call your API from a browser.\u003C/p\u003E\n\u003Cp\u003ECORS is a browser access control mechanism, not an authentication or authorization mechanism.\u003C/p\u003E\n\u003Cp\u003EA request must still pass the applicable authentication and authorization checks.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022resource-api-vs-uauthhub\u0022\u003EResource API vs UAuthHub\u003C/h3\u003E\n\u003Cp\u003EThese are different responsibilities.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EUAuthHub\u003C/th\u003E\n\u003Cth\u003EResource API\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EHosts authentication flows\u003C/td\u003E\n\u003Ctd\u003EHosts application resources\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EManages authentication sessions\u003C/td\u003E\n\u003Ctd\u003EValidates remote authentication context\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUses authentication persistence\u003C/td\u003E\n\u003Ctd\u003EDoes not require local authentication stores\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EProvides login and related endpoints\u003C/td\u003E\n\u003Ctd\u003EProvides application endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EServes authentication clients\u003C/td\u003E\n\u003Ctd\u003EServes authorized API consumers\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EUse a Resource API when you want independent deployment of business endpoints without duplicating the authentication server.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022resource-api-with-an-ef-core-backed-uauthhub\u0022\u003E8. Resource API with an EF Core-Backed UAuthHub\u003C/h2\u003E\n\u003Cp\u003EThe Resource API does not need to use EF Core just because UAuthHub does.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EInteractive WASM\n \u2502\n \u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25BA Resource API\n \u2502\n \u25BC\n UAuthHub\n \u2502\n \u25BC\n EF Core Database\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Hub stores users, credentials, sessions, and related security state in its database.\u003C/p\u003E\n\u003Cp\u003EThe Resource API communicates with the Hub through the configured remote validation integration.\u003C/p\u003E\n\u003Cp\u003EIts registration remains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthResourceApi(options =\u0026gt;\n{\n options.UAuthHubBaseUrl = \u0026quot;https://localhost:6112\u0026quot;;\n options.AllowedClientOrigins.Add(\u0026quot;https://localhost:6132\u0026quot;);\n options.CorsPolicyName = \u0026quot;resourceApi\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis separation allows the API and authentication infrastructure to be deployed, scaled, and maintained independently.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022calling-a-resource-api-from-blazor\u0022\u003E9. Calling a Resource API from Blazor\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth provides a unified Resource Client for accessing protected application APIs.\u003C/p\u003E\n\u003Cp\u003EInstead of configuring a separate HTTP client and implementing browser credential handling for ordinary resource requests, applications can use \u003Ccode\u003EIUAuthClient.Resource\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThis keeps authentication, identity operations, and application resource requests accessible through the same client SDK.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-resource-api\u0022\u003EConfigure the Resource API\u003C/h3\u003E\n\u003Cp\u003EFor a separate Resource API, configure its origin through the Blazor client registration:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client;\n\nbuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;;\n\n options.Resource.BaseAddress = \u0026quot;https://localhost:6122\u0026quot;;\n options.Resource.Credentials = UAuthResourceCredentialsMode.Auto;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EEndpoints.BasePath\u003C/code\u003E identifies the authentication server.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EResource.BaseAddress\u003C/code\u003E identifies the application Resource API.\u003C/p\u003E\n\u003Cp\u003EThese addresses can point to different applications.\u003C/p\u003E\n\u003Cp\u003EIf the Resource API shares the application\u0027s origin, \u003Ccode\u003EResource.BaseAddress\u003C/code\u003E can remain \u003Ccode\u003Enull\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022call-protected-resources\u0022\u003ECall Protected Resources\u003C/h3\u003E\n\u003Cp\u003EInject \u003Ccode\u003EIUAuthClient\u003C/code\u003E into a Blazor component:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@inject IUAuthClient UAuthClient\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThen retrieve application data:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Resource.GetAsync\u0026lt;List\u0026lt;Product\u0026gt;\u0026gt;(\u0026quot;/api/products\u0026quot;);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is not null)\n{\n products = result.Value;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Resource Client also supports POST, PUT, PATCH, and DELETE operations.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022application-specific-api-services\u0022\u003EApplication-Specific API Services\u003C/h3\u003E\n\u003Cp\u003EApplications can organize resource operations into small domain-specific services.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client;\nusing CodeBeam.UltimateAuth.Core.Contracts;\n\npublic sealed class ProductApiService(IUAuthClient client)\n{\n public Task\u0026lt;UAuthResult\u0026lt;List\u0026lt;Product\u0026gt;\u0026gt;\u0026gt; GetAllAsync()\n =\u0026gt; client.Resource.GetAsync\u0026lt;List\u0026lt;Product\u0026gt;\u0026gt;(\u0026quot;/api/products\u0026quot;);\n\n public Task\u0026lt;UAuthResult\u0026lt;Product\u0026gt;\u0026gt; CreateAsync(Product product)\n =\u0026gt; client.Resource.PostAsync\u0026lt;Product\u0026gt;(\u0026quot;/api/products\u0026quot;,product);\n\n public Task\u0026lt;UAuthResult\u0026gt; DeleteAsync(int id)\n =\u0026gt; client.Resource.DeleteAsync($\u0026quot;/api/products/{id}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERegister the application service:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddScoped\u0026lt;ProductApiService\u0026gt;();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe service uses UltimateAuth\u0027s transport and result contracts rather than implementing another HTTP abstraction.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022interactive-webassembly\u0022\u003EInteractive WebAssembly\u003C/h3\u003E\n\u003Cp\u003EIn an Interactive WebAssembly application, configure the UltimateAuth Blazor client in each project where the Resource Client will be used.\u003C/p\u003E\n\u003Cp\u003EThe host and \u003Ccode\u003E.Client\u003C/code\u003E project have separate dependency injection containers.\u003C/p\u003E\n\u003Cp\u003EA shared application API service may be registered in both projects when components execute in both environments.\u003C/p\u003E\n\u003Cp\u003EThe Resource Client uses browser-based transport, including when invoked from an interactive Blazor Server component.\u003C/p\u003E\n\u003Cp\u003EBrowser interop must be available before making resource requests. Do not initiate browser-dependent resource requests during static prerendering.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022standalone-webassembly\u0022\u003EStandalone WebAssembly\u003C/h3\u003E\n\u003Cp\u003EStandalone Blazor WebAssembly applications use the same Resource Client API.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;;\n options.Resource.BaseAddress = \u0026quot;https://localhost:6122\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EOnce configured, application components can call protected resources through \u003Ccode\u003EIUAuthClient.Resource\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EA separate application-specific \u003Ccode\u003EHttpClient\u003C/code\u003E is not required for these requests.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022browser-credentials-and-cors\u0022\u003EBrowser Credentials and CORS\u003C/h3\u003E\n\u003Cp\u003EFor cross-origin requests, the browser and receiving API must agree on credential handling.\u003C/p\u003E\n\u003Cp\u003EThe Resource Client supports \u003Ccode\u003EAuto\u003C/code\u003E, \u003Ccode\u003ESameOrigin\u003C/code\u003E, \u003Ccode\u003EInclude\u003C/code\u003E, and \u003Ccode\u003EOmit\u003C/code\u003E credential policies.\u003C/p\u003E\n\u003Cp\u003EFor cross-origin cookie-based authentication, configure the appropriate credential policy and ensure that the Resource API supports the required CORS and cookie settings.\u003C/p\u003E\n\u003Cp\u003EThe Resource Client does not automatically make an API authenticated merely because the user has signed in through UAuthHub.\u003C/p\u003E\n\u003Cp\u003EThe receiving API must validate the authentication material associated with the configured authentication mode and enforce its authorization policies.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-server-and-direct-service-calls\u0022\u003EBlazor Server and Direct Service Calls\u003C/h3\u003E\n\u003Cp\u003EBlazor Server applications can often access application services directly when those services are hosted in the same process.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar products = await productService.GetAllAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThere is no need to introduce an HTTP request solely to use the Resource Client.\u003C/p\u003E\n\u003Cp\u003EUse \u003Ccode\u003EIUAuthClient.Resource\u003C/code\u003E when the application actually needs to communicate with a protected Resource API through the browser.\u003C/p\u003E\n\u003Cp\u003EFor server-to-server requests, use an appropriate server-side HTTP client and authentication mechanism.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022learn-more\u0022\u003ELearn More\u003C/h3\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/client-sdk/resource-api-requests\u0022\u003EResource API Requests\u003C/a\u003E for supported operations, result handling, credential configuration, security considerations, and request cancellation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-modes-and-client-profiles\u0022\u003E10. Authentication Modes and Client Profiles\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth separates \u003Cstrong\u003Eclient profile detection\u003C/strong\u003E from \u003Cstrong\u003Eauthentication mode enforcement\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EThe default resolver maps client profiles to authentication modes.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EClient profile\u003C/th\u003E\n\u003Cth\u003EDefault resolved mode\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Server\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPureOpaque\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor WASM\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EWeb Server\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMAUI\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAPI\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPureJwt\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThis mapping describes the resolver\u0027s behavior, not a guarantee that every mode is currently fully supported by the server.\u003C/p\u003E\n\u003Cp\u003EIn particular, \u003Ccode\u003EPureJwt\u003C/code\u003E and \u003Ccode\u003ESemiHybrid\u003C/code\u003E have implementation limitations in the current release.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022restrict-allowed-modes\u0022\u003ERestrict allowed modes\u003C/h3\u003E\n\u003Cp\u003EOn the authentication server:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.AllowedModes =\n [\n UAuthMode.PureOpaque,\n UAuthMode.Hybrid\n ];\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EAllowedModes\u003C/code\u003E is a guardrail.\u003C/p\u003E\n\u003Cp\u003EIt does not select a mode for every request.\u003C/p\u003E\n\u003Cp\u003EThe effective mode is resolved from the client profile and then checked against the server\u0027s allowed-mode policy.\u003C/p\u003E\n\u003Cp\u003EFor most applications, use the default mode resolution unless you have a specific integration requirement.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-options-for-real-applications\u0022\u003E11. Server Options for Real Applications\u003C/h2\u003E\n\u003Cp\u003EProduction configuration should reflect your application\u0027s security requirements.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n options.Login.LockoutDuration = TimeSpan.FromMinutes(15);\n options.Identifiers.Behavior.AllowMultipleUsernames = true;\n options.UserProfile.EnableMultiProfile = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EServer options also compose the Core-defined login, session, token, PKCE, and multi-tenant options.\u003C/p\u003E\n\u003Cp\u003EConfigure those through \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EDo not independently configure Core options and assume the server will automatically use them.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuration-files\u0022\u003EConfiguration files\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth also supports configuration through \u003Ccode\u003Eappsettings.json\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;UltimateAuth\u0026quot;: {\n \u0026quot;Server\u0026quot;: {\n \u0026quot;Login\u0026quot;: {\n \u0026quot;MaxFailedAttempts\u0026quot;: 5,\n \u0026quot;LockoutDuration\u0026quot;: \u0026quot;00:15:00\u0026quot;\n }\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server options registration binds the \u003Ccode\u003EUltimateAuth:Server\u003C/code\u003E configuration section.\u003C/p\u003E\n\u003Cp\u003ERemember that effective mode defaults and mode-specific overrides are applied after the base options have been configured.\u003C/p\u003E\n\u003Cp\u003EFor detailed configuration behavior, see Server Options and Advanced Configuration.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-ip-address-reverse-proxy\u0022\u003E12. Client IP Address \u0026amp; Reverse Proxy\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth uses ASP.NET Core\u0027s resolved client IP address for device information and IP-based rate limiting. It does not directly trust \u003Ccode\u003EX-Forwarded-For\u003C/code\u003E or \u003Ccode\u003ECF-Connecting-IP\u003C/code\u003E headers.\u003C/p\u003E\n\u003Cp\u003EIf your application runs behind Caddy, Nginx, or another reverse proxy, configure ASP.NET Core to trust that proxy.\u003C/p\u003E\n\u003Cp\u003EIn \u003Ccode\u003EProgram.cs\u003C/code\u003E, before \u003Ccode\u003Ebuilder.Build()\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing System.Net;\nusing Microsoft.AspNetCore.HttpOverrides;\n\nbuilder.Services.Configure\u0026lt;ForwardedHeadersOptions\u0026gt;(options =\u0026gt;\n{\n options.ForwardedHeaders =\n ForwardedHeaders.XForwardedFor |\n ForwardedHeaders.XForwardedProto;\n\n // Replace with your actual Caddy or Nginx proxy IP.\n options.KnownProxies.Add(IPAddress.Parse(\u0026quot;172.20.0.5\u0026quot;));\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThen enable the middleware \u003Cstrong\u003Ebefore\u003C/strong\u003E UltimateAuth and HTTPS redirection:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseForwardedHeaders();\n\napp.UseHttpsRedirection();\napp.UseUltimateAuthWithAspNetCore();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe example IP \u003Ccode\u003E172.20.0.5\u003C/code\u003E represents a trusted reverse proxy, not the visitor\u0027s IP address. Replace it with your actual proxy IP. For Docker deployments, ensure the proxy address is stable or configure a narrowly scoped trusted network.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EImportant:\u003C/strong\u003E Never trust arbitrary forwarded headers or clear the trusted proxy lists to accept all sources. If your application connects directly to Kestrel without a reverse proxy, no forwarded headers configuration is required.\u003C/p\u003E\n\u003Cp\u003EFor multi-proxy deployments, configure the complete trusted proxy chain and forwarding behavior according to your infrastructure.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022development-and-production-differences\u0022\u003E13. Development and Production Differences\u003C/h2\u003E\n\u003Cp\u003EThe samples intentionally prioritize visibility and repeatability.\u003C/p\u003E\n\u003Cp\u003EA production application needs additional safeguards.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EArea\u003C/th\u003E\n\u003Cth\u003ESample or development\u003C/th\u003E\n\u003Cth\u003EProduction\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPersistence\u003C/td\u003E\n\u003Ctd\u003EInMemory or local SQLite\u003C/td\u003E\n\u003Ctd\u003EDurable database\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESeed accounts\u003C/td\u003E\n\u003Ctd\u003EExample users and passwords\u003C/td\u003E\n\u003Ctd\u003EReal provisioning\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EDatabase initialization\u003C/td\u003E\n\u003Ctd\u003EMay reset sample data\u003C/td\u003E\n\u003Ctd\u003EReviewed migrations\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EHTTPS\u003C/td\u003E\n\u003Ctd\u003ELocal development certificate\u003C/td\u003E\n\u003Ctd\u003EProper TLS termination\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECORS\u003C/td\u003E\n\u003Ctd\u003ELocalhost origins\u003C/td\u003E\n\u003Ctd\u003EExact trusted origins\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESigning keys\u003C/td\u003E\n\u003Ctd\u003EDevelopment defaults\u003C/td\u003E\n\u003Ctd\u003EManaged, persistent keys\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EDiagnostics\u003C/td\u003E\n\u003Ctd\u003EDetailed debugging\u003C/td\u003E\n\u003Ctd\u003ERestrict sensitive diagnostics\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECredential recovery\u003C/td\u003E\n\u003Ctd\u003EDevelopment notifier\u003C/td\u003E\n\u003Ctd\u003EReal secure notification channel\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EIdentifier verification\u003C/td\u003E\n\u003Ctd\u003EDevelopment bypass\u003C/td\u003E\n\u003Ctd\u003EProof-based verification\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESecrets\u003C/td\u003E\n\u003Ctd\u003ELocal settings\u003C/td\u003E\n\u003Ctd\u003ESecure configuration provider\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe sample projects include development-only implementations for credential reset notifications and identifier verification.\u003C/p\u003E\n\u003Cp\u003EThose implementations demonstrate extension points and should not be treated as production verification systems.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-recovery\u0022\u003ECredential recovery\u003C/h3\u003E\n\u003Cp\u003EIf your application exposes credential recovery, configure a real implementation of the credential reset notifier.\u003C/p\u003E\n\u003Cp\u003ERecovery secrets must be delivered through an appropriate channel and must not be exposed directly through anonymous API responses.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-verification\u0022\u003EIdentifier verification\u003C/h3\u003E\n\u003Cp\u003EIf your application supports verified email addresses, phone numbers, or other identifiers, use an implementation that verifies ownership through suitable proof.\u003C/p\u003E\n\u003Cp\u003EDo not use a development bypass verifier in production.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022signing-keys\u0022\u003ESigning keys\u003C/h3\u003E\n\u003Cp\u003EConfigure persistent signing keys for authentication modes that issue JWTs.\u003C/p\u003E\n\u003Cp\u003EDevelopment signing key providers are not a substitute for production key management.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022deployment-checklist\u0022\u003E14. Deployment Checklist\u003C/h2\u003E\n\u003Cp\u003EBefore deploying, verify the following:\u003C/p\u003E\n\u003Cul class=\u0022contains-task-list\u0022\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E The selected architecture matches the actual client hosting model.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E The authentication server uses appropriate persistent storage.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Database migrations have been reviewed and applied.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Sample users and development-only verification services are disabled.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Production signing keys and secrets are configured.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E HTTPS is enforced at the appropriate boundary.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E UAuthHub and Resource API origins are explicitly configured.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Authentication endpoints and application endpoints are protected appropriately.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Client and server endpoint URLs match the deployment.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Session expiration, refresh, logout, and revocation have been tested.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Protected resources enforce server-side authorization.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Reverse proxy headers are configured correctly if applicable.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Authentication state and persistence survive the expected application lifecycle.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-your-architecture\u0022\u003E15. Choosing Your Architecture\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EStart with one server and one client. Connect more clients whenever you need them.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EUltimateAuth is built around two fundamental responsibilities:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EUltimateAuth Server\u003C/strong\u003E \u2014 Manages authentication, users, credentials, sessions, authorization, and the security lifecycle.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EUltimateAuth Client\u003C/strong\u003E \u2014 Integrates an application with the authentication system using the appropriate client profile and authentication flows.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EEvery application needs access to an authentication server, but that server does not have to be a separate application. It can be hosted within your existing ASP.NET Core application or deployed independently as UAuthHub.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022single-application-the-simplest-setup\u0022\u003ESingle Application \u2014 The Simplest Setup\u003C/h3\u003E\n\u003Cp\u003EFor a traditional ASP.NET Core or Blazor Server application, UltimateAuth Server and Client can run together.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Your Application \u2502\n\u2502 \u2502\n\u2502 UltimateAuth Client \u2502\n\u2502 \u2502 \u2502\n\u2502 UltimateAuth Server \u2502\n\u2502 \u2502 \u2502\n\u2502 Database \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis approach is ideal when you want authentication without introducing another service or deployment.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022multiple-applications-one-authentication-server\u0022\u003EMultiple Applications \u2014 One Authentication Server\u003C/h3\u003E\n\u003Cp\u003EAs your system grows, you can connect multiple client applications to the same UltimateAuth Server.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E Blazor Server Blazor WASM\n \u2502 \u2502\n \u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n \u2502 \u2502 \u2502\n Razor Pages MAUI Other .NET Clients\n \u2502 \u2502 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2502\n \u25BC\n UltimateAuth Server\n UAuthHub\n \u2502\n \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u25BC \u25BC \u25BC\n Users Sessions Authorization\n \u2502\n \u25BC\n Persistence\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EEach client can have its own application structure, hosting model, and authentication requirements while sharing the same authentication authority.\u003C/p\u003E\n\u003Cp\u003EFor example, a business platform might use Blazor Server for administration, Blazor WASM for its customer portal, MAUI for mobile access, and Razor Pages for an internal application.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EAdding another client does not require replacing the existing clients or creating another UltimateAuth Server.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThe same users, credentials, security policies, and session infrastructure can serve multiple applications, subject to the authentication modes and client integrations supported by the installed version.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022distributed-applications-separate-resource-apis\u0022\u003EDistributed Applications \u2014 Separate Resource APIs\u003C/h3\u003E\n\u003Cp\u003EWhen your architecture includes independently deployed backend services, Resource APIs can integrate with the same authentication authority.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E Multiple Client Applications\n \u2502\n \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u25BC \u25BC\n UAuthHub Resource APIs\n \u25B2 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Authentication\n Validation\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EResource APIs protect application resources without becoming additional authentication servers.\u003C/p\u003E\n\u003Cp\u003EThis lets you separate business services from authentication infrastructure while maintaining a shared security model.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022one-foundation-different-scales\u0022\u003EOne Foundation, Different Scales\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EYour scenario\u003C/th\u003E\n\u003Cth\u003ERecommended approach\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EA simple Blazor Server application\u003C/td\u003E\n\u003Ctd\u003EIntegrated Server \u002B Client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EA traditional ASP.NET Core application\u003C/td\u003E\n\u003Ctd\u003EServer hosted within the application\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EA standalone Blazor WASM application\u003C/td\u003E\n\u003Ctd\u003EClient \u002B remote UAuthHub\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAn Interactive WASM application\u003C/td\u003E\n\u003Ctd\u003EHost \u002B WASM Client \u002B remote UAuthHub\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMultiple web, desktop, or mobile applications\u003C/td\u003E\n\u003Ctd\u003EMultiple Clients \u002B shared UAuthHub\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMultiple backend services\u003C/td\u003E\n\u003Ctd\u003EShared UAuthHub \u002B Resource APIs\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EUltimateAuth does not require a complex architecture to get started, and it does not restrict you to a single client as your application grows.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EStart with the simplest deployment that meets your needs. Add new client applications, persistent storage, or separate Resource APIs as your requirements evolve.\u003C/p\u003E\n\u003Cp\u003EThe authentication foundation remains consistent.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/getting-started/quickstart\u0022\u003EQuickStart\u003C/a\u003E \u2014 Build the minimal in-memory application.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/getting-started/ecosystem\u0022\u003EEcosystem\u003C/a\u003E \u2014 Explore packages and supported integrations.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/server-options\u0022\u003EServer Options\u003C/a\u003E \u2014 Configure server authentication and security behavior.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/client-options\u0022\u003EClient Options\u003C/a\u003E \u2014 Configure client profiles, endpoints, refresh, and reauthentication.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E \u2014 Explore effective options, dependency injection, and extension points.\u003C/li\u003E\n\u003C/ul\u003E\n", "Headings": [ { "Id": "choose-your-architecture", @@ -68,6 +68,11 @@ "Text": "Authentication behavior", "Level": 1 }, + { + "Id": "password-security-configuration", + "Text": "Password Security Configuration", + "Level": 1 + }, { "Id": "uauthhub-dedicated-authentication-server", "Text": "4. UAuthHub \u2014 Dedicated Authentication Server", @@ -339,6 +344,6 @@ "Level": 0 } ], - "SourceHash": "B1EA20A2729E11F13A9E2D7B12F6D1519AD7A45FE3DBFB4A9D79C01E06D0ED5A", + "SourceHash": "58DC0990FBB207C203C68D304A561D1ED8C87494BD4DEA86C1171568EA6B4466", "BuilderVersion": "1" } \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/plugin-domains/credentials.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/plugin-domains/credentials.json index 5b33614a..7d5d0681 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/plugin-domains/credentials.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/plugin-domains/credentials.json @@ -1,7 +1,7 @@ { "Slug": "plugin-domains/credentials", "Title": "Credentials", - "Html": "\n\u003Cp\u003EAuthentication establishes whether an entity can be trusted to act as a particular identity. A credential provides the evidence used to establish that trust.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth treats credentials as a distinct domain rather than embedding authentication secrets directly into user records or authentication flows.\u003C/p\u003E\n\u003Cp\u003EThe \u003Cstrong\u003ECredentials Plugin Domain\u003C/strong\u003E manages the lifecycle, verification, and security of authentication credentials while allowing the authentication engine to remain independent of a particular credential type.\u003C/p\u003E\n\u003Cp\u003EThe current reference implementation supports password credentials. Its architecture establishes the separation of responsibilities needed to accommodate additional credential mechanisms without redefining the underlying authentication model.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-credentials\u0022\u003EUnderstanding Credentials\u003C/h2\u003E\n\u003Cp\u003EA credential represents authentication material or a mechanism through which an identity can demonstrate possession of an accepted authentication factor.\u003C/p\u003E\n\u003Cp\u003ECredentials may take different forms:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ECredential mechanism\u003C/th\u003E\n\u003Cth\u003EAuthentication evidence\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EPassword\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EKnowledge of a secret verified against a protected representation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EOne-time code\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EPossession of a time-limited or single-use authentication value\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ECryptographic credential\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EProof of control over a private key or authenticator\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EExternal identity provider\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EA trusted authentication assertion established through an external protocol\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese mechanisms do not necessarily share the same storage, verification, or lifecycle requirements.\u003C/p\u003E\n\u003Cp\u003EFor example, a password requires secure secret verification, while a cryptographic credential may require signature validation and public-key metadata. An external provider may require validating protocol assertions rather than storing a reusable secret.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth\u0027s credential architecture is intended to accommodate these differences through explicit credential-specific behavior.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ECurrent implementation:\u003C/strong\u003E Password credentials are supported. The other mechanisms illustrate the broader credential model and its intended extension boundaries; they are not presented as implemented credential types.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentials-and-user-identity\u0022\u003ECredentials and User Identity\u003C/h2\u003E\n\u003Cp\u003EA user identity and a credential are separate concepts.\u003C/p\u003E\n\u003Cp\u003EThe Users domain manages identity ownership, identifiers, and account lifecycle. The Credentials domain manages the authentication material associated with an identity.\u003C/p\u003E\n\u003Cp\u003EThis separation means:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EA user can exist independently of a particular credential.\u003C/li\u003E\n\u003Cli\u003ECredential state can change without redefining the user\u0027s identity.\u003C/li\u003E\n\u003Cli\u003EAn identity can conceptually be associated with multiple authentication mechanisms.\u003C/li\u003E\n\u003Cli\u003ECredential verification does not replace user lifecycle or account eligibility checks.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EA username or email address is generally an \u003Cstrong\u003Eidentifier\u003C/strong\u003E, not a credential.\u003C/p\u003E\n\u003Cp\u003EAn identifier helps resolve which identity is involved in an authentication attempt. A credential provides evidence that the actor is entitled to authenticate as that identity.\u003C/p\u003E\n\u003Cp\u003EFor a password-based login, these responsibilities work together:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EThe submitted identifier is resolved to an identity.\u003C/li\u003E\n\u003Cli\u003EThe applicable password credential is located.\u003C/li\u003E\n\u003Cli\u003EThe submitted secret is verified.\u003C/li\u003E\n\u003Cli\u003EAuthentication policies and relevant security conditions are evaluated.\u003C/li\u003E\n\u003Cli\u003EThe authentication engine establishes authentication state if the flow succeeds.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EThe credential domain participates in establishing identity proof, but it does not independently determine the entire authentication outcome.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022responsibilities-across-the-framework\u0022\u003EResponsibilities Across the Framework\u003C/h2\u003E\n\u003Cp\u003ECredential support spans several architectural layers, each with a different responsibility.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ELayer\u003C/th\u003E\n\u003Cth\u003EResponsibility\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ECore\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EFoundational authentication contracts and security concepts\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EServer\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication flow execution, orchestration, and authentication-state management\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ECredentials Bridge\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EContracts connecting credential capabilities to the authentication server\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ECredentials Contracts\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EShared credential operation models and results\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ECredentials Reference Implementation\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential management, validation, recovery, and lifecycle behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EPersistence\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EStorage of credential records and related security state\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe distinction is between \u003Cstrong\u003Eusing credentials to authenticate\u003C/strong\u003E and \u003Cstrong\u003Emanaging credentials as domain resources\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EThe authentication engine coordinates authentication flows. The Credentials domain owns the rules governing credential records, their usability, and their lifecycle.\u003C/p\u003E\n\u003Cp\u003EAn application may integrate a custom credential system through the relevant authentication contracts without necessarily adopting the reference credential management implementation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022password-credentials\u0022\u003EPassword Credentials\u003C/h2\u003E\n\u003Cp\u003EPassword is the first supported credential mechanism in UltimateAuth.\u003C/p\u003E\n\u003Cp\u003EA password credential represents a password-based authentication factor associated with an identity.\u003C/p\u003E\n\u003Cp\u003EThe reference implementation separates the submitted secret from its protected representation and the credential\u0027s operational state.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022secret-protection\u0022\u003ESecret Protection\u003C/h3\u003E\n\u003Cp\u003EPasswords must not be stored as plaintext or returned through credential management responses.\u003C/p\u003E\n\u003Cp\u003EThe password verification process operates against a protected password representation using the configured password hashing infrastructure.\u003C/p\u003E\n\u003Cp\u003ECredential management APIs should expose only the information required for their intended operations, not the underlying secret.\u003C/p\u003E\n\u003Cp\u003EThis separation also allows password verification and password management to evolve without changing the user identity model.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-security-state\u0022\u003ECredential Security State\u003C/h3\u003E\n\u003Cp\u003EA credential is not necessarily usable merely because a matching secret has been supplied.\u003C/p\u003E\n\u003Cp\u003ECredential validation must also account for relevant lifecycle and security conditions, such as:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EWhether the credential is active or revoked.\u003C/li\u003E\n\u003Cli\u003EWhether an applicable expiration condition has been reached.\u003C/li\u003E\n\u003Cli\u003EWhether the credential belongs to the intended identity.\u003C/li\u003E\n\u003Cli\u003EWhether the requested operation is permitted.\u003C/li\u003E\n\u003Cli\u003EWhether the credential is eligible for the authentication mechanism being executed.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003ESecret verification and credential usability are related but distinct decisions.\u003C/p\u003E\n\u003Cp\u003EA correctly supplied password must not make a revoked or otherwise unusable credential valid.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-validation\u0022\u003ECredential Validation\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth separates credential-specific verification from the broader authentication flow.\u003C/p\u003E\n\u003Cp\u003EFor password authentication, credential validation includes evaluating the supplied secret against the stored protected representation and enforcing the relevant credential conditions.\u003C/p\u003E\n\u003Cp\u003EThe authentication server then uses the result within its authentication flow.\u003C/p\u003E\n\u003Cp\u003EThis boundary matters because credential verification is not equivalent to session creation or token issuance.\u003C/p\u003E\n\u003Cp\u003EA successful credential check establishes evidence for authentication. The authentication engine remains responsible for determining whether the flow can complete and what authentication state should be created.\u003C/p\u003E\n\u003Cp\u003EThe same architectural distinction applies to other credential mechanisms.\u003C/p\u003E\n\u003Cp\u003EA future cryptographic credential validator, for example, would verify a different kind of proof, but it would still participate in a broader authentication decision rather than independently issuing authentication state.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-lifecycle\u0022\u003ECredential Lifecycle\u003C/h2\u003E\n\u003Cp\u003ECredentials are managed resources with their own lifecycle.\u003C/p\u003E\n\u003Cp\u003EThe reference domain provides operations around credential creation, secret changes, revocation, and recovery.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EAdd\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003ERegister a credential for an identity\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EChange secret\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EReplace authentication material through an authorized operation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERevoke\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EMake a credential unusable\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EBegin reset\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EInitiate a controlled credential recovery process\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EComplete reset\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EReplace a secret after satisfying recovery requirements\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese operations have different security requirements.\u003C/p\u003E\n\u003Cp\u003EChanging a password while authenticated is not equivalent to recovering access without the existing password. Likewise, revoking a credential is not the same as invalidating every authentication session previously established using that credential.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth treats these distinctions explicitly rather than assuming that every credential mutation has the same security effect.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-ownership-and-operation-scope\u0022\u003ECredential Ownership and Operation Scope\u003C/h2\u003E\n\u003Cp\u003ECredential management operations must be evaluated in the context of the actor, the target identity, and the requested operation.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth distinguishes between self-service, administrative, and anonymous recovery scenarios.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022self-service-operations\u0022\u003ESelf-Service Operations\u003C/h3\u003E\n\u003Cp\u003EAn authenticated user may manage credentials associated with their own identity, subject to the applicable permissions and validation rules.\u003C/p\u003E\n\u003Cp\u003EChanging a secret may require proving knowledge of the existing secret or satisfying another supported security requirement.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-operations\u0022\u003EAdministrative Operations\u003C/h3\u003E\n\u003Cp\u003EAdministrative credential management is performed against an explicitly identified target.\u003C/p\u003E\n\u003Cp\u003EAdministrative authority does not eliminate the requirement to preserve target identity consistency.\u003C/p\u003E\n\u003Cp\u003EFor example, a credential recovery operation must not modify a different user\u0027s credential merely because a supplied identifier resolves to another account.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022anonymous-recovery\u0022\u003EAnonymous Recovery\u003C/h3\u003E\n\u003Cp\u003EAnonymous recovery allows an identity to regain access without an existing authenticated session.\u003C/p\u003E\n\u003Cp\u003EBecause the actor is not authenticated, the recovery process must establish sufficient proof before allowing credential modification.\u003C/p\u003E\n\u003Cp\u003EThese operation scopes are security boundaries, not merely different API routes.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-recovery\u0022\u003ECredential Recovery\u003C/h2\u003E\n\u003Cp\u003ECredential recovery is one of the most security-sensitive responsibilities of the Credentials domain.\u003C/p\u003E\n\u003Cp\u003EThe password reset process is designed as a controlled, multi-stage operation rather than an unrestricted secret update.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022beginning-recovery\u0022\u003EBeginning Recovery\u003C/h3\u003E\n\u003Cp\u003EA recovery request identifies the intended account and initiates a proof-based process.\u003C/p\u003E\n\u003Cp\u003EDepending on the supported recovery configuration, proof may take the form of a token or code delivered through an appropriate notification mechanism.\u003C/p\u003E\n\u003Cp\u003EThe proof must be bound to the intended recovery context and subject to server-controlled validity rules.\u003C/p\u003E\n\u003Cp\u003ERecovery material must not be exposed to an anonymous caller simply because that caller initiated the request.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022completing-recovery\u0022\u003ECompleting Recovery\u003C/h3\u003E\n\u003Cp\u003ECompleting recovery requires validating the supplied proof and its associated security conditions before applying a new secret.\u003C/p\u003E\n\u003Cp\u003EThe reference recovery model accounts for:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EProof validity and expiration.\u003C/li\u003E\n\u003Cli\u003ESingle-use recovery material.\u003C/li\u003E\n\u003Cli\u003EConsistency between the recovery target and intended identity.\u003C/li\u003E\n\u003Cli\u003ERejection of invalid or previously consumed proof.\u003C/li\u003E\n\u003Cli\u003EProtection against unauthorized credential replacement.\u003C/li\u003E\n\u003Cli\u003ESecurity-state updates following successful recovery.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EA completed recovery must not leave the same proof available for another successful reset.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022enumeration-resistance\u0022\u003EEnumeration Resistance\u003C/h3\u003E\n\u003Cp\u003EAnonymous recovery endpoints must avoid unnecessarily revealing whether an identifier belongs to an existing account.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth separates externally observable recovery responses from the internal decisions required to locate and manage the account.\u003C/p\u003E\n\u003Cp\u003EThis reduces account enumeration opportunities while preserving the ability to enforce recovery rules.\u003C/p\u003E\n\u003Cp\u003ERecovery behavior should also be combined with appropriate abuse protection and operational monitoring.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentials-and-authentication-sessions\u0022\u003ECredentials and Authentication Sessions\u003C/h2\u003E\n\u003Cp\u003EChanging or recovering a credential may require action against authentication sessions that were established before the change.\u003C/p\u003E\n\u003Cp\u003EHowever, \u003Cstrong\u003Ecredential lifecycle and session lifecycle are separate responsibilities\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth supports security policies that determine how credential changes and resets affect existing sessions.\u003C/p\u003E\n\u003Cp\u003EThe available policy model includes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EPolicy\u003C/th\u003E\n\u003Cth\u003EIntended behavior\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EKeepAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPreserve existing sessions\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EKeepCurrent\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPreserve the current session while revoking other applicable sessions\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke all applicable sessions\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe appropriate policy depends on the operation and the application\u0027s security requirements.\u003C/p\u003E\n\u003Cp\u003EA password reset performed through anonymous recovery may warrant different treatment from an authenticated password change.\u003C/p\u003E\n\u003Cp\u003EEnforcement also depends on the authentication mode.\u003C/p\u003E\n\u003Cp\u003EIn session-authoritative modes, revocation can be reflected through server-side session validation. In JWT-based modes, previously issued access tokens may remain usable until expiration unless additional validation mechanisms are involved.\u003C/p\u003E\n\u003Cp\u003ECredential changes therefore initiate the applicable security response; they do not imply identical immediate token invalidation across every mode.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/fundamentals/auth-modes\u0022\u003EAuthentication Modes\u003C/a\u003E for the underlying differences.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022integration-with-the-users-domain\u0022\u003EIntegration with the Users Domain\u003C/h2\u003E\n\u003Cp\u003EThe Credentials domain does not need to own user records to manage authentication material associated with an identity.\u003C/p\u003E\n\u003Cp\u003EUser creation, deletion, or other lifecycle changes may require corresponding credential operations.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth supports coordination through integration contracts rather than requiring the Users and Credentials implementations to depend directly on one another.\u003C/p\u003E\n\u003Cp\u003EFor example, an application may coordinate initial credential enrollment with user creation or perform credential cleanup as part of account removal.\u003C/p\u003E\n\u003Cp\u003EThese behaviors belong to explicit integration workflows.\u003C/p\u003E\n\u003Cp\u003ECreating a user does not inherently require creating a password credential, and deleting a user should not rely on an undocumented side effect to maintain credential consistency.\u003C/p\u003E\n\u003Cp\u003EThis distinction keeps identity lifecycle and credential lifecycle independently understandable.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022extending-credential-capabilities\u0022\u003EExtending Credential Capabilities\u003C/h2\u003E\n\u003Cp\u003ECredential mechanisms differ substantially in how they establish trust.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth\u0027s architecture is intended to support additional mechanisms by separating credential-specific verification and management from authentication flow orchestration.\u003C/p\u003E\n\u003Cp\u003ESuch extensions may require their own:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ECredential representation and metadata.\u003C/li\u003E\n\u003Cli\u003EEnrollment and verification procedures.\u003C/li\u003E\n\u003Cli\u003ESecurity and expiration rules.\u003C/li\u003E\n\u003Cli\u003ERecovery or revocation behavior.\u003C/li\u003E\n\u003Cli\u003EPersistence and integration contracts.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003ENot every credential mechanism needs to implement password-style secret changes or recovery.\u003C/p\u003E\n\u003Cp\u003EThe goal is to preserve consistent authentication boundaries while allowing each credential mechanism to enforce its own security requirements.\u003C/p\u003E\n\u003Cp\u003EFor most applications using password authentication, the reference implementation provides the recommended starting point.\u003C/p\u003E\n\u003Cp\u003EApplications with specialized requirements can customize supported services and integrations or provide alternative implementations through the relevant contracts.\u003C/p\u003E\n\u003Cp\u003EDetailed extension and replacement strategies belong in the dedicated customization documentation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022architectural-perspective\u0022\u003EArchitectural Perspective\u003C/h2\u003E\n\u003Cp\u003EThe Credentials domain establishes an important distinction within UltimateAuth:\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EA user identifies the account. A credential provides authentication evidence. The authentication engine decides how that evidence establishes authentication state.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EKeeping these responsibilities separate allows the framework to support identity management without tying authentication to one user entity, one secret format, or one credential mechanism.\u003C/p\u003E\n\u003Cp\u003EThe current password implementation provides the operational foundation. The broader architecture allows credential capabilities to evolve without turning the authentication engine into a monolithic credential management system.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains/users\u0022\u003EUsers\u003C/a\u003E \u2014 Understand identity ownership, identifiers, and account lifecycle.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains/authorization\u0022\u003EAuthorization\u003C/a\u003E \u2014 Explore how authenticated identities are evaluated against access policies.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains\u0022\u003EPlugin Domains\u003C/a\u003E \u2014 Review the architecture and responsibilities of Plugin Domains.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor the authentication flow that consumes credential verification results, see \u003Ca href=\u0022/docs/fundamentals/flow-based-authentication\u0022\u003EFlow-Based Authentication\u003C/a\u003E.\u003C/p\u003E\n", + "Html": "\n\u003Cp\u003EAuthentication establishes whether an entity can be trusted to act as a particular identity. A credential provides the evidence used to establish that trust.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth treats credentials as a distinct domain rather than embedding authentication secrets directly into user records or authentication flows.\u003C/p\u003E\n\u003Cp\u003EThe \u003Cstrong\u003ECredentials Plugin Domain\u003C/strong\u003E manages the lifecycle, verification, and security of authentication credentials while allowing the authentication engine to remain independent of a particular credential type.\u003C/p\u003E\n\u003Cp\u003EThe current reference implementation supports password credentials. Its architecture establishes the separation of responsibilities needed to accommodate additional credential mechanisms without redefining the underlying authentication model.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-credentials\u0022\u003EUnderstanding Credentials\u003C/h2\u003E\n\u003Cp\u003EA credential represents authentication material or a mechanism through which an identity can demonstrate possession of an accepted authentication factor.\u003C/p\u003E\n\u003Cp\u003ECredentials may take different forms:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ECredential mechanism\u003C/th\u003E\n\u003Cth\u003EAuthentication evidence\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EPassword\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EKnowledge of a secret verified against a protected representation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EOne-time code\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EPossession of a time-limited or single-use authentication value\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ECryptographic credential\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EProof of control over a private key or authenticator\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EExternal identity provider\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EA trusted authentication assertion established through an external protocol\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese mechanisms do not necessarily share the same storage, verification, or lifecycle requirements.\u003C/p\u003E\n\u003Cp\u003EFor example, a password requires secure secret verification, while a cryptographic credential may require signature validation and public-key metadata. An external provider may require validating protocol assertions rather than storing a reusable secret.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth\u0027s credential architecture is intended to accommodate these differences through explicit credential-specific behavior.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ECurrent implementation:\u003C/strong\u003E Password credentials are supported. The other mechanisms illustrate the broader credential model and its intended extension boundaries; they are not presented as implemented credential types.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentials-and-user-identity\u0022\u003ECredentials and User Identity\u003C/h2\u003E\n\u003Cp\u003EA user identity and a credential are separate concepts.\u003C/p\u003E\n\u003Cp\u003EThe Users domain manages identity ownership, identifiers, and account lifecycle. The Credentials domain manages the authentication material associated with an identity.\u003C/p\u003E\n\u003Cp\u003EThis separation means:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EA user can exist independently of a particular credential.\u003C/li\u003E\n\u003Cli\u003ECredential state can change without redefining the user\u0027s identity.\u003C/li\u003E\n\u003Cli\u003EAn identity can conceptually be associated with multiple authentication mechanisms.\u003C/li\u003E\n\u003Cli\u003ECredential verification does not replace user lifecycle or account eligibility checks.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EA username or email address is generally an \u003Cstrong\u003Eidentifier\u003C/strong\u003E, not a credential.\u003C/p\u003E\n\u003Cp\u003EAn identifier helps resolve which identity is involved in an authentication attempt. A credential provides evidence that the actor is entitled to authenticate as that identity.\u003C/p\u003E\n\u003Cp\u003EFor a password-based login, these responsibilities work together:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EThe submitted identifier is resolved to an identity.\u003C/li\u003E\n\u003Cli\u003EThe applicable password credential is located.\u003C/li\u003E\n\u003Cli\u003EThe submitted secret is verified.\u003C/li\u003E\n\u003Cli\u003EAuthentication policies and relevant security conditions are evaluated.\u003C/li\u003E\n\u003Cli\u003EThe authentication engine establishes authentication state if the flow succeeds.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EThe credential domain participates in establishing identity proof, but it does not independently determine the entire authentication outcome.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022responsibilities-across-the-framework\u0022\u003EResponsibilities Across the Framework\u003C/h2\u003E\n\u003Cp\u003ECredential support spans several architectural layers, each with a different responsibility.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ELayer\u003C/th\u003E\n\u003Cth\u003EResponsibility\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ECore\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EFoundational authentication contracts and security concepts\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EServer\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication flow execution, orchestration, and authentication-state management\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ECredentials Bridge\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EContracts connecting credential capabilities to the authentication server\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ECredentials Contracts\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EShared credential operation models and results\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ECredentials Reference Implementation\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential management, validation, recovery, and lifecycle behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EPersistence\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EStorage of credential records and related security state\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe distinction is between \u003Cstrong\u003Eusing credentials to authenticate\u003C/strong\u003E and \u003Cstrong\u003Emanaging credentials as domain resources\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EThe authentication engine coordinates authentication flows. The Credentials domain owns the rules governing credential records, their usability, and their lifecycle.\u003C/p\u003E\n\u003Cp\u003EAn application may integrate a custom credential system through the relevant authentication contracts without necessarily adopting the reference credential management implementation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022password-credentials\u0022\u003EPassword Credentials\u003C/h2\u003E\n\u003Cp\u003EPassword is the first supported credential mechanism in UltimateAuth.\u003C/p\u003E\n\u003Cp\u003EA password credential represents a password-based authentication factor associated with an identity.\u003C/p\u003E\n\u003Cp\u003EThe reference implementation separates the submitted secret from its protected representation and the credential\u0027s operational state.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022secret-protection\u0022\u003ESecret Protection\u003C/h3\u003E\n\u003Cp\u003EPasswords must not be stored as plaintext or returned through credential management responses.\u003C/p\u003E\n\u003Cp\u003EThe password verification process operates against a protected password representation using the configured password hashing infrastructure.\u003C/p\u003E\n\u003Cp\u003ECredential management APIs should expose only the information required for their intended operations, not the underlying secret.\u003C/p\u003E\n\u003Cp\u003EThis separation also allows password verification and password management to evolve without changing the user identity model.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-security-state\u0022\u003ECredential Security State\u003C/h3\u003E\n\u003Cp\u003EA credential is not necessarily usable merely because a matching secret has been supplied.\u003C/p\u003E\n\u003Cp\u003ECredential validation must also account for relevant lifecycle and security conditions, such as:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EWhether the credential is active or revoked.\u003C/li\u003E\n\u003Cli\u003EWhether an applicable expiration condition has been reached.\u003C/li\u003E\n\u003Cli\u003EWhether the credential belongs to the intended identity.\u003C/li\u003E\n\u003Cli\u003EWhether the requested operation is permitted.\u003C/li\u003E\n\u003Cli\u003EWhether the credential is eligible for the authentication mechanism being executed.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003ESecret verification and credential usability are related but distinct decisions.\u003C/p\u003E\n\u003Cp\u003EA correctly supplied password must not make a revoked or otherwise unusable credential valid.\u003C/p\u003E\n\u003Cp\u003EPassword strength, common-password detection, user similarity, and password reuse are governed by configurable security policies.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/security/password-policy\u0022\u003EPassword Policy \u0026amp; History\u003C/a\u003E for the built-in policy levels, defaults, and configuration options.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-validation\u0022\u003ECredential Validation\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth separates credential-specific verification from the broader authentication flow.\u003C/p\u003E\n\u003Cp\u003EFor password authentication, credential validation includes evaluating the supplied secret against the stored protected representation and enforcing the relevant credential conditions.\u003C/p\u003E\n\u003Cp\u003EThe authentication server then uses the result within its authentication flow.\u003C/p\u003E\n\u003Cp\u003EThis boundary matters because credential verification is not equivalent to session creation or token issuance.\u003C/p\u003E\n\u003Cp\u003EA successful credential check establishes evidence for authentication. The authentication engine remains responsible for determining whether the flow can complete and what authentication state should be created.\u003C/p\u003E\n\u003Cp\u003EThe same architectural distinction applies to other credential mechanisms.\u003C/p\u003E\n\u003Cp\u003EA future cryptographic credential validator, for example, would verify a different kind of proof, but it would still participate in a broader authentication decision rather than independently issuing authentication state.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-lifecycle\u0022\u003ECredential Lifecycle\u003C/h2\u003E\n\u003Cp\u003ECredentials are managed resources with their own lifecycle.\u003C/p\u003E\n\u003Cp\u003EThe reference domain provides operations around credential creation, secret changes, revocation, and recovery.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EAdd\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003ERegister a credential for an identity\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EChange secret\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EReplace authentication material through an authorized operation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERevoke\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EMake a credential unusable\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EBegin reset\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EInitiate a controlled credential recovery process\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EComplete reset\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EReplace a secret after satisfying recovery requirements\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese operations have different security requirements.\u003C/p\u003E\n\u003Cp\u003EChanging a password while authenticated is not equivalent to recovering access without the existing password. Likewise, revoking a credential is not the same as invalidating every authentication session previously established using that credential.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth treats these distinctions explicitly rather than assuming that every credential mutation has the same security effect.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-ownership-and-operation-scope\u0022\u003ECredential Ownership and Operation Scope\u003C/h2\u003E\n\u003Cp\u003ECredential management operations must be evaluated in the context of the actor, the target identity, and the requested operation.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth distinguishes between self-service, administrative, and anonymous recovery scenarios.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022self-service-operations\u0022\u003ESelf-Service Operations\u003C/h3\u003E\n\u003Cp\u003EAn authenticated user may manage credentials associated with their own identity, subject to the applicable permissions and validation rules.\u003C/p\u003E\n\u003Cp\u003EChanging a secret may require proving knowledge of the existing secret or satisfying another supported security requirement.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-operations\u0022\u003EAdministrative Operations\u003C/h3\u003E\n\u003Cp\u003EAdministrative credential management is performed against an explicitly identified target.\u003C/p\u003E\n\u003Cp\u003EAdministrative authority does not eliminate the requirement to preserve target identity consistency.\u003C/p\u003E\n\u003Cp\u003EFor example, a credential recovery operation must not modify a different user\u0027s credential merely because a supplied identifier resolves to another account.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022anonymous-recovery\u0022\u003EAnonymous Recovery\u003C/h3\u003E\n\u003Cp\u003EAnonymous recovery allows an identity to regain access without an existing authenticated session.\u003C/p\u003E\n\u003Cp\u003EBecause the actor is not authenticated, the recovery process must establish sufficient proof before allowing credential modification.\u003C/p\u003E\n\u003Cp\u003EThese operation scopes are security boundaries, not merely different API routes.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-recovery\u0022\u003ECredential Recovery\u003C/h2\u003E\n\u003Cp\u003ECredential recovery is one of the most security-sensitive responsibilities of the Credentials domain.\u003C/p\u003E\n\u003Cp\u003EThe password reset process is designed as a controlled, multi-stage operation rather than an unrestricted secret update.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022beginning-recovery\u0022\u003EBeginning Recovery\u003C/h3\u003E\n\u003Cp\u003EA recovery request identifies the intended account and initiates a proof-based process.\u003C/p\u003E\n\u003Cp\u003EDepending on the supported recovery configuration, proof may take the form of a token or code delivered through an appropriate notification mechanism.\u003C/p\u003E\n\u003Cp\u003EThe proof must be bound to the intended recovery context and subject to server-controlled validity rules.\u003C/p\u003E\n\u003Cp\u003ERecovery material must not be exposed to an anonymous caller simply because that caller initiated the request.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022completing-recovery\u0022\u003ECompleting Recovery\u003C/h3\u003E\n\u003Cp\u003ECompleting recovery requires validating the supplied proof and its associated security conditions before applying a new secret.\u003C/p\u003E\n\u003Cp\u003EThe reference recovery model accounts for:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EProof validity and expiration.\u003C/li\u003E\n\u003Cli\u003ESingle-use recovery material.\u003C/li\u003E\n\u003Cli\u003EConsistency between the recovery target and intended identity.\u003C/li\u003E\n\u003Cli\u003ERejection of invalid or previously consumed proof.\u003C/li\u003E\n\u003Cli\u003EProtection against unauthorized credential replacement.\u003C/li\u003E\n\u003Cli\u003ESecurity-state updates following successful recovery.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EA completed recovery must not leave the same proof available for another successful reset.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022enumeration-resistance\u0022\u003EEnumeration Resistance\u003C/h3\u003E\n\u003Cp\u003EAnonymous recovery endpoints must avoid unnecessarily revealing whether an identifier belongs to an existing account.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth separates externally observable recovery responses from the internal decisions required to locate and manage the account.\u003C/p\u003E\n\u003Cp\u003EThis reduces account enumeration opportunities while preserving the ability to enforce recovery rules.\u003C/p\u003E\n\u003Cp\u003ERecovery behavior should also be combined with appropriate abuse protection and operational monitoring.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentials-and-authentication-sessions\u0022\u003ECredentials and Authentication Sessions\u003C/h2\u003E\n\u003Cp\u003EChanging or recovering a credential may require action against authentication sessions that were established before the change.\u003C/p\u003E\n\u003Cp\u003EHowever, \u003Cstrong\u003Ecredential lifecycle and session lifecycle are separate responsibilities\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth supports security policies that determine how credential changes and resets affect existing sessions.\u003C/p\u003E\n\u003Cp\u003EThe available policy model includes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EPolicy\u003C/th\u003E\n\u003Cth\u003EIntended behavior\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EKeepAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPreserve existing sessions\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EKeepCurrent\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPreserve the current session while revoking other applicable sessions\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke all applicable sessions\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe appropriate policy depends on the operation and the application\u0027s security requirements.\u003C/p\u003E\n\u003Cp\u003EA password reset performed through anonymous recovery may warrant different treatment from an authenticated password change.\u003C/p\u003E\n\u003Cp\u003EEnforcement also depends on the authentication mode.\u003C/p\u003E\n\u003Cp\u003EIn session-authoritative modes, revocation can be reflected through server-side session validation. In JWT-based modes, previously issued access tokens may remain usable until expiration unless additional validation mechanisms are involved.\u003C/p\u003E\n\u003Cp\u003ECredential changes therefore initiate the applicable security response; they do not imply identical immediate token invalidation across every mode.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/fundamentals/auth-modes\u0022\u003EAuthentication Modes\u003C/a\u003E for the underlying differences.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022integration-with-the-users-domain\u0022\u003EIntegration with the Users Domain\u003C/h2\u003E\n\u003Cp\u003EThe Credentials domain does not need to own user records to manage authentication material associated with an identity.\u003C/p\u003E\n\u003Cp\u003EUser creation, deletion, or other lifecycle changes may require corresponding credential operations.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth supports coordination through integration contracts rather than requiring the Users and Credentials implementations to depend directly on one another.\u003C/p\u003E\n\u003Cp\u003EFor example, an application may coordinate initial credential enrollment with user creation or perform credential cleanup as part of account removal.\u003C/p\u003E\n\u003Cp\u003EThese behaviors belong to explicit integration workflows.\u003C/p\u003E\n\u003Cp\u003ECreating a user does not inherently require creating a password credential, and deleting a user should not rely on an undocumented side effect to maintain credential consistency.\u003C/p\u003E\n\u003Cp\u003EThis distinction keeps identity lifecycle and credential lifecycle independently understandable.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022extending-credential-capabilities\u0022\u003EExtending Credential Capabilities\u003C/h2\u003E\n\u003Cp\u003ECredential mechanisms differ substantially in how they establish trust.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth\u0027s architecture is intended to support additional mechanisms by separating credential-specific verification and management from authentication flow orchestration.\u003C/p\u003E\n\u003Cp\u003ESuch extensions may require their own:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ECredential representation and metadata.\u003C/li\u003E\n\u003Cli\u003EEnrollment and verification procedures.\u003C/li\u003E\n\u003Cli\u003ESecurity and expiration rules.\u003C/li\u003E\n\u003Cli\u003ERecovery or revocation behavior.\u003C/li\u003E\n\u003Cli\u003EPersistence and integration contracts.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003ENot every credential mechanism needs to implement password-style secret changes or recovery.\u003C/p\u003E\n\u003Cp\u003EThe goal is to preserve consistent authentication boundaries while allowing each credential mechanism to enforce its own security requirements.\u003C/p\u003E\n\u003Cp\u003EFor most applications using password authentication, the reference implementation provides the recommended starting point.\u003C/p\u003E\n\u003Cp\u003EApplications with specialized requirements can customize supported services and integrations or provide alternative implementations through the relevant contracts.\u003C/p\u003E\n\u003Cp\u003EDetailed extension and replacement strategies belong in the dedicated customization documentation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022architectural-perspective\u0022\u003EArchitectural Perspective\u003C/h2\u003E\n\u003Cp\u003EThe Credentials domain establishes an important distinction within UltimateAuth:\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EA user identifies the account. A credential provides authentication evidence. The authentication engine decides how that evidence establishes authentication state.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EKeeping these responsibilities separate allows the framework to support identity management without tying authentication to one user entity, one secret format, or one credential mechanism.\u003C/p\u003E\n\u003Cp\u003EThe current password implementation provides the operational foundation. The broader architecture allows credential capabilities to evolve without turning the authentication engine into a monolithic credential management system.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains/users\u0022\u003EUsers\u003C/a\u003E \u2014 Understand identity ownership, identifiers, and account lifecycle.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains/authorization\u0022\u003EAuthorization\u003C/a\u003E \u2014 Explore how authenticated identities are evaluated against access policies.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains\u0022\u003EPlugin Domains\u003C/a\u003E \u2014 Review the architecture and responsibilities of Plugin Domains.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor the authentication flow that consumes credential verification results, see \u003Ca href=\u0022/docs/fundamentals/flow-based-authentication\u0022\u003EFlow-Based Authentication\u003C/a\u003E.\u003C/p\u003E\n", "Headings": [ { "Id": "understanding-credentials", @@ -109,6 +109,6 @@ "Level": 0 } ], - "SourceHash": "8618A9ED0DF478D9B489CBC7C51420145259EB1DBA1956617ECF3600D163BF2F", + "SourceHash": "5EEC9DEC644EEAB9AFBF7958DBA283FA11077DADF5ABBFCF3FB7493087B17106", "BuilderVersion": "1" } \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/security/password-policy.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/security/password-policy.json new file mode 100644 index 00000000..5391c5d7 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/security/password-policy.json @@ -0,0 +1,94 @@ +{ + "Slug": "security/password-policy", + "Title": "Password Policy \u0026 History", + "Html": "\n\u003Cp\u003EUltimateAuth provides configurable password security policies for applications using its reference password credential implementation.\u003C/p\u003E\n\u003Cp\u003EInstead of requiring every application to implement password validation independently, UltimateAuth provides three built-in policy levels: \u003Cstrong\u003EBasic\u003C/strong\u003E, \u003Cstrong\u003EStandard\u003C/strong\u003E, and \u003Cstrong\u003EStrict\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EEach level defines a starting set of password requirements. Developers can override individual requirements without replacing the entire policy.\u003C/p\u003E\n\u003Cp\u003EPassword validation is enforced on the server. Client applications can retrieve the effective policy to display requirements and provide immediate feedback.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022password-policy-levels\u0022\u003EPassword Policy Levels\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth includes three built-in presets.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EStandard is the default policy level.\u003C/strong\u003E\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ERequirement\u003C/th\u003E\n\u003Cth\u003EBasic\u003C/th\u003E\n\u003Cth\u003EStandard (Default)\u003C/th\u003E\n\u003Cth\u003EStrict\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMinimum length\u003C/td\u003E\n\u003Ctd\u003E8\u003C/td\u003E\n\u003Ctd\u003E8\u003C/td\u003E\n\u003Ctd\u003E15\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMaximum length\u003C/td\u003E\n\u003Ctd\u003E128\u003C/td\u003E\n\u003Ctd\u003E128\u003C/td\u003E\n\u003Ctd\u003E128\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERequire letter\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERequire digit\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERequire uppercase\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERequire lowercase\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERequire special character\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECommon-password detection\u003C/td\u003E\n\u003Ctd\u003EBasic\u003C/td\u003E\n\u003Ctd\u003EStandard\u003C/td\u003E\n\u003Ctd\u003EStrict\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUser-similarity detection\u003C/td\u003E\n\u003Ctd\u003EBasic\u003C/td\u003E\n\u003Ctd\u003EStandard\u003C/td\u003E\n\u003Ctd\u003EStrict\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPrevious passwords remembered\u003C/td\u003E\n\u003Ctd\u003E0\u003C/td\u003E\n\u003Ctd\u003E0\u003C/td\u003E\n\u003Ctd\u003E0\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese values are the built-in defaults, not fixed limits for every application.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022basic\u0022\u003EBasic\u003C/h3\u003E\n\u003Cp\u003EBasic provides a minimum password length without requiring particular character classes.\u003C/p\u003E\n\u003Cp\u003EIt still enables basic common-password and user-similarity checks.\u003C/p\u003E\n\u003Cp\u003EThis preset is intended for applications that prefer fewer composition requirements while retaining server-side checks against predictable passwords.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022standard\u0022\u003EStandard\u003C/h3\u003E\n\u003Cp\u003EStandard is the default UltimateAuth password policy.\u003C/p\u003E\n\u003Cp\u003EIt requires at least eight characters, including a letter and a digit, and enables Standard-level common-password and user-similarity checks.\u003C/p\u003E\n\u003Cp\u003EApplications do not need to explicitly select Standard unless they want to make their configuration intentional.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022strict\u0022\u003EStrict\u003C/h3\u003E\n\u003Cp\u003EStrict increases the minimum length to 15 characters and requires letters, digits, uppercase letters, lowercase letters, and special characters.\u003C/p\u003E\n\u003Cp\u003EIt also selects Strict-level common-password and user-similarity checks.\u003C/p\u003E\n\u003Cp\u003EStrict is available for applications that require more restrictive password composition rules. It is not automatically the best policy for every application: usability, password-manager compatibility, account recovery, and the application\u0027s threat model should also be considered.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuring-password-policies\u0022\u003EConfiguring Password Policies\u003C/h2\u003E\n\u003Cp\u003EPassword policies are configured through \u003Ccode\u003EUAuthServerOptions.Password\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022use-a-built-in-preset\u0022\u003EUse a built-in preset\u003C/h3\u003E\n\u003Cp\u003EIn \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Credentials.Contracts;\n\nbuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Password.Level = PasswordPolicyLevel.Strict;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis applies the Strict preset, including its default length, character requirements, and optional security checks.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022customize-individual-requirements\u0022\u003ECustomize individual requirements\u003C/h3\u003E\n\u003Cp\u003EYou can override any supported requirement without creating a custom policy implementation.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Credentials.Contracts;\n\nbuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Password.Level = PasswordPolicyLevel.Standard;\n\n options.Password.MinLength = 12;\n options.Password.MaxLength = 64;\n\n options.Password.RequireUppercase = true;\n options.Password.RequireSpecialCharacter = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe effective policy starts with Standard and applies the explicitly configured overrides.\u003C/p\u003E\n\u003Cp\u003EUnspecified requirements retain their preset values.\u003C/p\u003E\n\u003Cp\u003EFor example, this configuration still requires a letter and a digit, even though those properties are not explicitly set.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022disable-an-individual-security-check\u0022\u003EDisable an individual security check\u003C/h3\u003E\n\u003Cp\u003ECommon-password detection and user-similarity detection are configured independently from the main preset.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Password.Level = PasswordPolicyLevel.Standard;\n\n options.Password.CommonPasswords =\n PasswordPolicyLevel.Strict;\n\n options.Password.UserSimilarity =\n PasswordPolicyLevel.Disabled;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis keeps Standard password composition rules, increases common-password detection to Strict, and disables user-similarity detection.\u003C/p\u003E\n\u003Cp\u003EDisabling security checks should be a deliberate application decision.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022password-length-limits\u0022\u003EPassword Length Limits\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth defines the following limits:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ESetting\u003C/th\u003E\n\u003Cth\u003EValue\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMinimum configurable password length\u003C/td\u003E\n\u003Ctd\u003E6\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EDefault maximum password length\u003C/td\u003E\n\u003Ctd\u003E128\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAbsolute input length limit\u003C/td\u003E\n\u003Ctd\u003E256\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe built-in presets use a maximum length of 128 characters.\u003C/p\u003E\n\u003Cp\u003EApplications can customize the configured maximum within the supported limits. The absolute limit is a defensive boundary and is not intended to replace application-level password policy configuration.\u003C/p\u003E\n\u003Cp\u003EInvalid password policy options are rejected during server options validation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022common-password-detection\u0022\u003ECommon-Password Detection\u003C/h2\u003E\n\u003Cp\u003EA password may satisfy every character requirement and still be easy to guess.\u003C/p\u003E\n\u003Cp\u003EFor example, a password such as \u003Ccode\u003Eqwerty123\u003C/code\u003E may satisfy Standard\u0027s letter, digit, and length requirements but still be rejected by the server\u0027s common-password checker.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth supports independently configurable detection levels:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EDisabled\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EBasic\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EStandard\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EStrict\u003C/code\u003E\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe selected level determines the checker configuration used by the reference implementation.\u003C/p\u003E\n\u003Cp\u003ECommon-password detection is a server-side operation. A client-side character validator cannot establish whether a password passes this check.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022user-similarity-detection\u0022\u003EUser-Similarity Detection\u003C/h2\u003E\n\u003Cp\u003EPasswords that closely resemble account identifiers or other available user information may be predictable.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth provides user-similarity checking with the same configurable levels:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EDisabled\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EBasic\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EStandard\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EStrict\u003C/code\u003E\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe server evaluates similarity using the available password-policy context.\u003C/p\u003E\n\u003Cp\u003EClient applications should not attempt to reproduce the complete similarity decision from public policy information alone.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022password-history\u0022\u003EPassword History\u003C/h2\u003E\n\u003Cp\u003EPassword history helps prevent users from repeatedly selecting passwords they have used before.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth can remember a configurable number of previous passwords and reject reuse during applicable password credential operations.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022enable-password-history\u0022\u003EEnable password history\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Password.History.RememberLast = 5;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ERememberLast\u003C/code\u003E controls how many previous passwords are checked.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EValue\u003C/th\u003E\n\u003Cth\u003EBehavior\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003E0\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPrevious-password history checking is disabled\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003E1\u201310\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECheck up to the configured number of previous passwords\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe default value is \u003Ccode\u003E0\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe reference password-history validator also checks whether the submitted password matches the current credential, independently of the configured previous-password count.\u003C/p\u003E\n\u003Cp\u003EPassword history compares password candidates against protected password representations; it does not require retaining previous passwords in plaintext.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022persistence\u0022\u003EPersistence\u003C/h3\u003E\n\u003Cp\u003EPassword history requires an appropriate history store.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth provides reference storage implementations for in-memory and Entity Framework Core persistence.\u003C/p\u003E\n\u003Cp\u003EFor persistent deployments, apply the required database migrations before enabling password-history functionality.\u003C/p\u003E\n\u003Cp\u003EAn in-memory store is useful for development and testing but does not provide durable history across application restarts.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-enforcement-and-client-feedback\u0022\u003EServer Enforcement and Client Feedback\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth distinguishes between authoritative password validation and client-side guidance.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EThe server is the security boundary.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EClient applications may retrieve the effective policy and validate its publicly expressible requirements, including:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EMinimum and maximum length\u003C/li\u003E\n\u003Cli\u003ELetter and digit requirements\u003C/li\u003E\n\u003Cli\u003EUppercase and lowercase requirements\u003C/li\u003E\n\u003Cli\u003ESpecial-character requirements\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EOther checks remain server-authoritative:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ECommon-password detection\u003C/li\u003E\n\u003Cli\u003EUser-similarity detection\u003C/li\u003E\n\u003Cli\u003EPassword history\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EA password passing client-side validation is not guaranteed to be accepted by the server.\u003C/p\u003E\n\u003Cp\u003EApplications should handle server-side password-policy violations as expected validation errors, rather than displaying them as unexpected application failures.\u003C/p\u003E\n\u003Cp\u003EFor client API usage and examples, see \u003Ca href=\u0022/docs/client/credentials\u0022\u003EClient SDK \u2014 Credentials\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-considerations\u0022\u003ESecurity Considerations\u003C/h2\u003E\n\u003Cp\u003EPassword policies are one part of credential security, not a replacement for the broader authentication system.\u003C/p\u003E\n\u003Cp\u003EApplications should also consider secure password hashing, transport security, login throttling, account recovery, session revocation, and appropriate authorization for credential-management operations.\u003C/p\u003E\n\u003Cp\u003EChanging a password may also affect existing authentication sessions, depending on the configured credential session policy.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/security/session-security\u0022\u003ESession Security Model\u003C/a\u003E for the relevant session security boundaries.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains/credentials\u0022\u003ECredentials Domain\u003C/a\u003E \u2014 Understand credential ownership, validation, and lifecycle.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/credentials\u0022\u003EClient SDK \u2014 Credentials\u003C/a\u003E \u2014 Retrieve password requirements and implement credential-management flows.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/getting-started/real-world-setup\u0022\u003EReal-World Setup\u003C/a\u003E \u2014 Configure UltimateAuth in a real application.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/security/session-security\u0022\u003ESession Security Model\u003C/a\u003E \u2014 Understand the security consequences of credential changes.\u003C/li\u003E\n\u003C/ul\u003E\n", + "Headings": [ + { + "Id": "password-policy-levels", + "Text": "Password Policy Levels", + "Level": 0 + }, + { + "Id": "basic", + "Text": "Basic", + "Level": 1 + }, + { + "Id": "standard", + "Text": "Standard", + "Level": 1 + }, + { + "Id": "strict", + "Text": "Strict", + "Level": 1 + }, + { + "Id": "configuring-password-policies", + "Text": "Configuring Password Policies", + "Level": 0 + }, + { + "Id": "use-a-built-in-preset", + "Text": "Use a built-in preset", + "Level": 1 + }, + { + "Id": "customize-individual-requirements", + "Text": "Customize individual requirements", + "Level": 1 + }, + { + "Id": "disable-an-individual-security-check", + "Text": "Disable an individual security check", + "Level": 1 + }, + { + "Id": "password-length-limits", + "Text": "Password Length Limits", + "Level": 0 + }, + { + "Id": "common-password-detection", + "Text": "Common-Password Detection", + "Level": 0 + }, + { + "Id": "user-similarity-detection", + "Text": "User-Similarity Detection", + "Level": 0 + }, + { + "Id": "password-history", + "Text": "Password History", + "Level": 0 + }, + { + "Id": "enable-password-history", + "Text": "Enable password history", + "Level": 1 + }, + { + "Id": "persistence", + "Text": "Persistence", + "Level": 1 + }, + { + "Id": "server-enforcement-and-client-feedback", + "Text": "Server Enforcement and Client Feedback", + "Level": 0 + }, + { + "Id": "security-considerations", + "Text": "Security Considerations", + "Level": 0 + }, + { + "Id": "next-steps", + "Text": "Next Steps", + "Level": 0 + } + ], + "SourceHash": "2A46CBD9FFD1BA41AD57260E775CF74BA7F5DCD319F1415FDD2F68A6A8AF0BCF", + "BuilderVersion": "1" +} \ No newline at end of file diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CredentialDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CredentialDialog.razor index 9f32bd10..def7795b 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CredentialDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CredentialDialog.razor @@ -1,4 +1,5 @@ -@inject IUAuthClient UAuthClient +@using CodeBeam.UltimateAuth.Credentials.Contracts +@inject IUAuthClient UAuthClient @inject ISnackbar Snackbar @@ -83,8 +84,27 @@ Immediate="true" Required="true" RequiredError="New password is required." + MaxLength="@PasswordPolicyLimits.AbsoluteMaxLength" + Validation="@(new Func(ValidateNewPassword))" Disabled="@_isSubmitting" /> + @if (_passwordPolicy is not null && PasswordRequirementsContent is not null) + { + @PasswordRequirementsContent(new PasswordRequirementsContext(_passwordPolicy, _newPassword)) + } + else if (_passwordPolicyLoading) + { + + Loading password requirements... + + } + else + { + + Password requirements are validated by the server. + + } + ? PasswordRequirementsContent { get; set; } + [Parameter] public bool PreviewMode { get; set; } + protected override async Task OnInitializedAsync() + { + try + { + var result = await UAuthClient.Credentials + .GetPasswordPolicyAsync(); + + if (result.IsSuccess) + _passwordPolicy = result.Value; + } + catch (Exception) + { + // The server remains authoritative. + } + finally + { + _passwordPolicyLoading = false; + } + } + + private string? ValidateNewPassword(string? value) + { + if (string.IsNullOrEmpty(value)) + return "New password is required."; + + if (_passwordPolicy is null) + return null; + + var errors = UAuthPasswordClientValidator.Validate( + value, _passwordPolicy); + + return errors.Count == 0 + ? null + : "Password does not meet the configured requirements."; + } + + private async Task ChangePasswordAsync() { if (PreviewMode || _isSubmitting || _form is null) diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index bedc9fb6..b6184b9a 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Client.Blazor.Extensions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Credentials; +using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.InMemory; using CodeBeam.UltimateAuth.Sample.BlazorServer.Components; using CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/RateLimitingTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/RateLimitingTests.cs index d1a92bee..ee55d5a5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/RateLimitingTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/RateLimitingTests.cs @@ -169,6 +169,7 @@ public async Task EveryMappedEndpointHasOneClassification() /auth/me/sessions/revoke-all|RevokeAllMyChains|Standard /auth/me/sessions/revoke-others|RevokeOtherChains|Standard /auth/me/status|ChangeStatusSelf|Standard +/auth/password-policy|PasswordPolicy|Standard /auth/pkce/authorize|PkceAuthorize|Sensitive /auth/pkce/complete|PkceComplete|Sensitive /auth/pkce/try-complete|PkceTryComplete|Sensitive diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RateLimitingPartitionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RateLimitingPartitionTests.cs index 87f1b924..fef13244 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RateLimitingPartitionTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RateLimitingPartitionTests.cs @@ -39,9 +39,9 @@ public async Task RejectionForwardsNativeRetryMetadata() public void EveryEndpointHasTheReviewedClassification() { var all = Enum.GetValues().Select(UAuthEndpointClassification.Get).ToArray(); - Assert.Equal(79, all.Length); + Assert.Equal(80, all.Length); Assert.Equal(20, all.Count(x => x.Group == UAuthRateLimitGroup.Sensitive)); - Assert.Equal(55, all.Count(x => x.Group == UAuthRateLimitGroup.Standard)); + Assert.Equal(56, all.Count(x => x.Group == UAuthRateLimitGroup.Standard)); Assert.Equal(4, all.Count(x => x.Group == UAuthRateLimitGroup.Frequent)); Assert.Equal(UAuthRateLimitGroup.Sensitive, UAuthEndpointClassification.Get(UAuthEndpoint.CredentialResetBeginAnonymous).Group); Assert.Equal(UAuthRateLimitGroup.Standard, UAuthEndpointClassification.Get(UAuthEndpoint.VerifyUserIdentifierAdmin).Group); From 198b00b92a52e1ed0ebfbe8ed13d6bcbf635f7d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 2 Nov 2026 13:59:24 +0300 Subject: [PATCH 09/10] Last Arrangements --- ...text.cs => PasswordRequirementsContext.cs} | 0 .../Contracts/Authority/AccessContext.cs | 5 + .../Auth/Context/AccessContextFactory.cs | 4 +- .../ResourceAccessContextBuilder.cs | 2 + .../IPasswordPolicyEndpointHandler.cs | 2 +- .../PasswordPolicyEndpointHandler.cs | 29 +++- .../Endpoints/UAuthEndpointRegistrar.cs | 4 +- .../Extensions/ServiceCollectionExtensions.cs | 2 +- .../PasswordUserLifecycleIntegration.cs | 5 +- ....cs => UAuthPasswordValidationPipeline.cs} | 2 +- .../Services/CredentialManagementService.cs | 7 +- .../Services/UserApplicationService.cs | 2 +- .../Abstractions/IUserLifecycleIntegration.cs | 3 +- .../AssemblyBehavior.cs | 1 + .../Infrastructure/Contexts.cs | 40 +++-- .../docs/execution-report.md | 48 ------ .../docs/page-render-coverage.md | 143 ------------------ .../docs/security-coverage.md | 48 ------ .../docs/service-regression-coverage.md | 119 --------------- .../Infrastructure/TestAccessContext.cs | 5 + .../Infrastructure/TestAccessContext.cs | 5 + .../UserLifecycleTests.cs | 2 + .../PasswordValidationPipelineTests.cs | 4 +- .../Helpers/TestAccessContext.cs | 4 + .../Policies/AccessPoliciesTests.cs | 4 + .../Server/SessionApplicationServiceTests.cs | 1 + .../Server/SessionEndpointHandlerTests.cs | 5 +- .../UAuthResourceAccessOrchestratorTests.cs | 6 +- .../Users/UserApplicationServiceTests.cs | 4 + .../Users/UserEndpointHandlerTests.cs | 8 +- 30 files changed, 117 insertions(+), 397 deletions(-) rename samples/CodeBeam.UltimateAuth.Sample.Shared/Contracts/{PasswordRequirementContext.cs => PasswordRequirementsContext.cs} (100%) rename src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/{DefaultPasswordValidationPipeline.cs => UAuthPasswordValidationPipeline.cs} (90%) create mode 100644 tests/CodeBeam.UltimateAuth.IdentityLab.Tests/AssemblyBehavior.cs delete mode 100644 tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/execution-report.md delete mode 100644 tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/page-render-coverage.md delete mode 100644 tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/security-coverage.md delete mode 100644 tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/service-regression-coverage.md diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Contracts/PasswordRequirementContext.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Contracts/PasswordRequirementsContext.cs similarity index 100% rename from samples/CodeBeam.UltimateAuth.Sample.Shared/Contracts/PasswordRequirementContext.cs rename to samples/CodeBeam.UltimateAuth.Sample.Shared/Contracts/PasswordRequirementsContext.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs index e71108bb..8ea5c30b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using System.Collections; namespace CodeBeam.UltimateAuth.Core.Contracts; @@ -31,6 +32,7 @@ public sealed class AccessContext public TenantKey ResourceTenant { get; init; } public string Action { get; init; } = default!; + public UAuthClientProfile ClientProfile { get; } public IReadOnlyDictionary Attributes { get; init; } = EmptyAttributes.Instance; public bool IsCrossTenant => !string.Equals(ActorTenant, ResourceTenant, StringComparison.Ordinal); @@ -62,6 +64,7 @@ internal AccessContext( UserKey? targetUserKey, TenantKey resourceTenant, string action, + UAuthClientProfile clientProfile, IReadOnlyDictionary attributes, IReadOnlyCollection? targetUserKeys = null) { @@ -79,6 +82,7 @@ internal AccessContext( ResourceTenant = resourceTenant; Action = action; + ClientProfile = clientProfile; Attributes = attributes; } @@ -100,6 +104,7 @@ public AccessContext WithAttribute(string key, object value) TargetUserKey, ResourceTenant, Action, + ClientProfile, merged, TargetUserKeys ); diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs index 3dd551e3..ae0a47a3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs @@ -1,5 +1,4 @@ -using CodeBeam.UltimateAuth.Authorization; -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; @@ -96,6 +95,7 @@ private async Task CreateInternalAsync(AuthFlowContext authFlow, targetUserKey: targetUserKey, resourceTenant: resourceTenant, action: action, + clientProfile: authFlow.ClientProfile, attributes: attrs.Count > 0 ? new ReadOnlyDictionary(attrs) : EmptyAttributes.Instance, diff --git a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/ResourceAccessContextBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/ResourceAccessContextBuilder.cs index d1b8fe2f..bea8a3d6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/ResourceAccessContextBuilder.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/ResourceAccessContextBuilder.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Core.Runtime; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Extensions; @@ -96,6 +97,7 @@ public static async Task CreateAsync(HttpContext http, string act targetUserKey: null, resourceTenant: tenant, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: attributes); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPasswordPolicyEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPasswordPolicyEndpointHandler.cs index 386d96da..3454b1f0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPasswordPolicyEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPasswordPolicyEndpointHandler.cs @@ -4,5 +4,5 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints; public interface IPasswordPolicyEndpointHandler { - IResult GetAsync(HttpContext context); + Task GetAsync(HttpContext context); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/PasswordPolicyEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PasswordPolicyEndpointHandler.cs index e5abda41..6d4ad788 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/PasswordPolicyEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/PasswordPolicyEndpointHandler.cs @@ -1,18 +1,37 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; + +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Options; using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Infrastructure; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Endpoints; -internal sealed class PasswordPolicyEndpointHandler(IAuthFlowContextAccessor authFlow) : IPasswordPolicyEndpointHandler +internal sealed class PasswordPolicyEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, IAccessOrchestrator accessOrchestrator) : IPasswordPolicyEndpointHandler { - public IResult GetAsync(HttpContext context) + public async Task GetAsync(HttpContext context) { var flow = authFlow.Current; - var effective = PasswordPolicyOptionsResolver.Resolve(flow.Options.Password); - UAuthPasswordPolicyInfo info = effective.ToInfo(); + var accessContext = await accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.GetPasswordPolicyAnonymous, + resource: "credentials", + ct: context.RequestAborted); + + var command = new AccessCommand( + ct => + { + ct.ThrowIfCancellationRequested(); + + var effective = PasswordPolicyOptionsResolver.Resolve(flow.Options.Password); + + return Task.FromResult(effective.ToInfo()); + }); + + var info = await accessOrchestrator.ExecuteAsync(accessContext, command, context.RequestAborted); context.Response.Headers.CacheControl = "no-store"; diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index 716c857f..ac01f945 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -333,8 +333,8 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options var adminCredentials = admin.MapGroup("/users/{userKey}/credentials"); if (Enabled(UAuthActions.Credentials.GetPasswordPolicyAnonymous)) - group.MapPost("/password-policy", ([FromServices] IPasswordPolicyEndpointHandler h, HttpContext context) - => h.GetAsync(context)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)).WithUAuthRateLimit(UAuthEndpoint.PasswordPolicy); + group.MapPost("/password-policy", async ([FromServices] IPasswordPolicyEndpointHandler h, HttpContext context) + => await h.GetAsync(context)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)).WithUAuthRateLimit(UAuthEndpoint.PasswordPolicy); if (Enabled(UAuthActions.Credentials.AddSelf)) selfCredentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs index bef9cdc2..7bc3cfb1 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs @@ -16,7 +16,7 @@ public static IServiceCollection AddUltimateAuthCredentialsReference(this IServi services.TryAddScoped(); services.AddScoped(); services.TryAddScoped(); - services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); return services; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs index 50599072..25697d9c 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs @@ -33,7 +33,7 @@ public PasswordUserLifecycleIntegration(IPasswordCredentialStoreFactory credenti _clock = clock; } - public async Task OnUserCreatedAsync(TenantKey tenant, UserKey userKey, object request, CancellationToken ct) + public async Task OnUserCreatedAsync(TenantKey tenant, UserKey userKey, object request, UAuthClientProfile clientProfile, CancellationToken ct) { if (request is not CreateUserRequest r) return; @@ -42,8 +42,7 @@ public async Task OnUserCreatedAsync(TenantKey tenant, UserKey userKey, object r return; var passwordContext = await _passwordContextProvider.CreateAsync(tenant, userKey, r.Password, ct); - // TODO: This is not correct client profile. Resolve true client profile in the future. - var effectiveServer = _effectiveServerOptionsProvider.GetEffective(tenant, UAuthClientProfile.NotSpecified); + var effectiveServer = _effectiveServerOptionsProvider.GetEffective(tenant, clientProfile); var passwordOptions = PasswordPolicyOptionsResolver.Resolve(effectiveServer.Options.Password); await _passwordWriter.CreateAsync( tenant, diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/DefaultPasswordValidationPipeline.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/UAuthPasswordValidationPipeline.cs similarity index 90% rename from src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/DefaultPasswordValidationPipeline.cs rename to src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/UAuthPasswordValidationPipeline.cs index 37113b70..7fb9fbfa 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/DefaultPasswordValidationPipeline.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/UAuthPasswordValidationPipeline.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference; -public sealed class DefaultPasswordValidationPipeline(IPasswordStrengthPolicy strengthPolicy, IPasswordHistoryValidator historyValidator) : IPasswordValidationPipeline +public sealed class UAuthPasswordValidationPipeline(IPasswordStrengthPolicy strengthPolicy, IPasswordHistoryValidator historyValidator) : IPasswordValidationPipeline { public async Task ValidateAsync(PasswordValidationRequest request, EffectivePasswordPolicyOptions options, CancellationToken ct = default) { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs index a686361f..7a2ef4c0 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -120,8 +120,7 @@ public async Task AddAsync(AccessContext context, AddCreden var subjectUser = context.GetTargetUserKey(); var passwordContext = await _passwordContextProvider.CreateAsync(context.ResourceTenant, subjectUser, request.Secret, atomicCt); - // TODO: This is not correct client profile. Resolve true client profile in the future. - var effectiveServer = _effectiveServerOptionsProvider.GetEffective(context.ResourceTenant, UAuthClientProfile.NotSpecified); + var effectiveServer = _effectiveServerOptionsProvider.GetEffective(context.ResourceTenant, context.ClientProfile); var passwordOptions = PasswordPolicyOptionsResolver.Resolve(effectiveServer.Options.Password); var credential = await _passwordWriter.CreateAsync( @@ -173,7 +172,7 @@ public async Task ChangeSecretAsync(AccessContext contex } var passwordContext = await _passwordContextProvider.CreateAsync(context.ResourceTenant, subjectUser, request.NewSecret, atomicCt); - var effectiveServer = _effectiveServerOptionsProvider.GetEffective(context.ResourceTenant, UAuthClientProfile.NotSpecified); + var effectiveServer = _effectiveServerOptionsProvider.GetEffective(context.ResourceTenant, context.ClientProfile); var passwordOptions = PasswordPolicyOptionsResolver.Resolve(effectiveServer.Options.Password); await _passwordWriter.ChangeAsync(pwd, request.NewSecret, passwordContext, passwordOptions, now, atomicCt); @@ -420,7 +419,7 @@ public async Task CompleteResetAsync(AccessContext conte // Strength + current password + history validation. // Validation failure throws before token consumption. var passwordContext = await _passwordContextProvider.CreateAsync(context.ResourceTenant, userKey, request.NewSecret, atomicCt); - var effectiveServer = _effectiveServerOptionsProvider.GetEffective(context.ResourceTenant, UAuthClientProfile.NotSpecified); + var effectiveServer = _effectiveServerOptionsProvider.GetEffective(context.ResourceTenant, context.ClientProfile); var passwordOptions = PasswordPolicyOptionsResolver.Resolve(effectiveServer.Options.Password); try diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index cf5e6bb1..20a32026 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -175,7 +175,7 @@ await identifierStore.AddAsync( foreach (var integration in _integrations) { // Credential creation handle on here - await integration.OnUserCreatedAsync(context.ResourceTenant, userKey, request, atomicCt); + await integration.OnUserCreatedAsync(context.ResourceTenant, userKey, request, context.ClientProfile, atomicCt); } return UserCreateResult.Success(userKey); diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleIntegration.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleIntegration.cs index c07c0ac8..36673ec4 100644 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleIntegration.cs +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleIntegration.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; namespace CodeBeam.UltimateAuth.Users; @@ -10,7 +11,7 @@ namespace CodeBeam.UltimateAuth.Users; /// public interface IUserLifecycleIntegration { - Task OnUserCreatedAsync(TenantKey tenant, UserKey userKey, object request, CancellationToken ct = default); + Task OnUserCreatedAsync(TenantKey tenant, UserKey userKey, object request, UAuthClientProfile clientProfile, CancellationToken ct = default); Task OnUserDeletedAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, CancellationToken ct = default); } diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/AssemblyBehavior.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/AssemblyBehavior.cs new file mode 100644 index 00000000..c7fc3b1f --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/AssemblyBehavior.cs @@ -0,0 +1 @@ +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/Contexts.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/Contexts.cs index a9175b3d..dbc99af6 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/Contexts.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/Contexts.cs @@ -1,7 +1,8 @@ -using System.Reflection; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using System.Reflection; namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Infrastructure; @@ -9,13 +10,35 @@ internal static class Contexts { // AccessContext deliberately has no public constructor. Keep reflection confined // to this adapter rather than adding production friend assemblies or public APIs. - public static AccessContext Create(string action, UserKey? actor = null, UserKey? target = null, - bool system = false, IReadOnlyDictionary? attributes = null, - IReadOnlyCollection? targets = null) - => (AccessContext)typeof(AccessContext).GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic) - .Single().Invoke(new object?[] { actor, TenantKeys.Single, actor.HasValue, system, - null, Array.Empty(), "identitylab.tests", target, TenantKeys.Single, - action, attributes ?? new Dictionary(), targets }); + public static AccessContext Create(string action, UserKey? actor = null, UserKey? target = null, bool system = false, + IReadOnlyDictionary? attributes = null, IReadOnlyCollection? targets = null) + { + var constructor = typeof(AccessContext).GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic).Single(); + + var args = constructor.GetParameters() + .Select(parameter => parameter.Name switch + { + "actorUserKey" => (object?)actor, + "actorTenant" => TenantKeys.Single, + "isAuthenticated" => actor.HasValue, + "isSystemActor" => system, + "actorChainId" => null, + "actorRoles" => Array.Empty(), + "resource" => "identitylab.tests", + "targetUserKey" => target, + "resourceTenant" => TenantKeys.Single, + "action" => action, + "attributes" => attributes ?? new Dictionary(), + "targetUserKeys" => targets, + "clientProfile" => UAuthClientProfile.NotSpecified, + _ => throw new InvalidOperationException( + $"Unexpected AccessContext parameter: {parameter.Name}") + }) + .ToArray(); + + return (AccessContext)constructor.Invoke(args); + } + public static CodeBeam.UltimateAuth.Server.Auth.AuthFlowContext AuthenticatedFlow(UserKey actor) { var constructor = typeof(CodeBeam.UltimateAuth.Server.Auth.AuthFlowContext) @@ -29,5 +52,4 @@ public static CodeBeam.UltimateAuth.Server.Auth.AuthFlowContext AuthenticatedFlo }).ToArray(); return (CodeBeam.UltimateAuth.Server.Auth.AuthFlowContext)constructor.Invoke(args); } - } diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/execution-report.md b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/execution-report.md deleted file mode 100644 index 2faaffd1..00000000 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/execution-report.md +++ /dev/null @@ -1,48 +0,0 @@ -# Execution report — 2026-10-30 - -Branch: `test/identity-lab-security`, based on `dev` at `64d1dc5` (Identity Lab: UI Polish, Authorization Playground & Diagnostics, #118). Remote `dev` was fetched and confirmed to match the supplied checkout. No production source was changed. No merge or push was performed. - -## Implemented and executed - -.NET SDK 10.0.403; Release configuration; PostgreSQL 17 (Testcontainers 4.16.0); actual Lab and framework migrations. Every relational test uses an isolated database. The real integration runtime composes framework services, both PostgreSQL contexts, seed contributors, and Identity Lab policies; focused tests mock selected stores/clients and authenticated context sources. - -| Identity Lab area | Passed | Failed | Skipped | -|---|---:|---:|---:| -| Security: admin guard, role policies, root resolution | 15 | 0 | 0 | -| Authorization: two-sandbox targets, status, owner restrictions | 10 | 0 | 0 | -| Services: demo roles/store, repair, account deletion | 20 | 0 | 0 | -| Provisioning: forged actors/tokens, rollback replay, registration | 7 | 0 | 0 | -| Integration: real registration/seeding/provisioning, context attribute forgery, role denials, fault cleanup, relational invariants | 14 | 0 | 0 | -| Regression: mismatched system role identity | 0 | 1 | 0 | -| **Total** | **66** | **1** | **0** | - -The enabled failing regression expresses required rejection. It was not skipped, inverted or excluded from CI. Final Identity Lab execution took 33 seconds after build. All 67 discovered cases executed. - -Existing EF integration tests: **3 passed, 0 failed, 0 skipped**. Existing unit tests: **2,754 passed, 0 failed, 0 skipped**. Existing broader integration tests: **375 passed, 0 failed, 0 skipped** (4 minutes 44 seconds). Across existing projects: **3,132 passed, 0 failed, 0 skipped**. - -## Confirmed integrity defect: system-role ID validation gap - -Runtime reproduction is encoded in `Regression/SystemRoleIdentityTests.cs`: - -1. Configure the role-store contract to resolve normalized `OWNER` to a Role whose ID is `LabRoleIds.Admin`. -2. Return no assignments for a regular target user. -3. Invoke `LabRoleService.AssignAsync(singleTenant, target, "Owner")`. -4. The implementation calls AssignAsync with the returned Admin ID. The regression requires InvalidOperationException before assignment and fails because no exception is thrown. - -This requires a malformed role definition or compromised store; no public operation that creates that definition was established. Admin seed validation normally rejects mismatched system IDs. This is a reproduced defense-in-depth integrity defect, **not a demonstrated ordinary-user escalation**. The helper is not registered as a public role-mutation endpoint. Production code remains unchanged. - -The real-persistence tests separately confirm that ordinary role-service attempts to acquire Owner/Ladmin or mutate the admin role are denied without altering stored role state. The real context factory replaces forged typed Ladmin attributes with stored Owner role IDs, and the sandbox policy still denies the foreign target. Legitimate registration creates Owner rather than Ladmin, trusted seeding assigns Ladmin only to the fixed administrator, and member provisioning/demo assignment preserves regular user boundaries. - -Other risks, not confirmed exploits: registration permits partial account creation and returns RequiresRepair after some failures; multiple services trust actor-key arguments supplied by their callers. Authenticated transport tests are required before classifying those as exploitable defects. - -## Infrastructure and verification - -Initial shell attempts could not find dotnet or reach the proxy/Docker socket. Enabling network access for the shell command made the configured infrastructure accessible; a local .NET 10 SDK was installed under work/. These initial limitations were resolved. No relational tests were substituted or disabled. The test project explicitly pins EF Core 10.0.12 to match the application and avoid transitive 10.0.5 assembly conflicts. Testcontainers was updated to 4.16.0 after restore reported advisories in an older transitive SSH.NET dependency. - -`git diff --check` passes. Application builds emit an existing MudBlazor analyzer warning about Disabled on MudSelectItemGroupExtended; production code was left unchanged. CI itself has not been run remotely, and the external-PostgreSQL connection-string alternative was not separately exercised. - -## CI readiness and remaining boundaries - -The project is in UltimateAuth.slnx. Existing CI excludes it from the general matrix test step and runs it in a dedicated .NET 10 job using Docker/Testcontainers, with TRX uploaded even on failure. No continue-on-error or defect exclusion was added. - -**Not ready to require as a green CI gate:** the preserved integrity regression fails and needs separate triage/approved production work. Full hosted HTTP/cookie/context authenticity, diagnostics transport, standalone identifier availability, complete deletion cleanup, owner/admin repair, registration fault recovery, and cancellation/restart boundaries remain partial or untested. See the coverage matrix for exact boundaries. Passing the other tests is not a claim of complete security coverage. diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/page-render-coverage.md b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/page-render-coverage.md deleted file mode 100644 index 7f0d71e3..00000000 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/page-render-coverage.md +++ /dev/null @@ -1,143 +0,0 @@ -# Identity Lab route smoke coverage - -## Baseline - -The initial ten render cases are preserved. The checkout was fast-forwarded from -`45dcfcd` to the latest available local `origin/dev`, `b886b94` (critical service -workflow regressions, #132). `HEAD...origin/dev` reports `0 0`. The merged service -regression tests are unchanged by this implementation. - -`git fetch origin dev` still fails because the configured proxy at port 8080 is -unreachable. The live remote tip could not be verified; this is based on the -available local dev ref, not a claim of live synchronization. - -## Inventory - -There are **24 routable page components with 24 route attributes** in the supplied -application source. All 24 have an actual render test mapping; there are no -routable exclusions. Counts are statically checked, not executed test results. - -| Component | Route | Render scenario | -| --- | --- | --- | -| LandingPage | `/` | Existing public registration entry | -| LoginPage | `/login` | Existing anonymous form and authenticated entry | -| RegisterPage | `/register` | Existing anonymous form | -| DashboardPage | `/dashboard` | Existing Owner and User navigation | -| MySessionsPage | `/dashboard/sessions` | Existing empty, loading and failure states | -| AuthorizationPage | `/dashboard/authorization` | Existing empty and failure states | -| UsersPage | `/dashboard/users` | Owner, active sandbox, one member summary | -| MyIdentityPage | `/dashboard/identity` | User, populated profile, empty identifiers, credential section | -| MyAccessPage | `/dashboard/access` | User, sandbox membership, empty roles and permissions | -| SettingsPage | `/dashboard/settings` | Owner, sandbox settings and resolved owner name | -| ActivityPage | `/dashboard/activity` | User, active sandbox, no activity | -| AdminPage | `/admin` | Ladmin with matching server role assignment, empty platform, real repair panel | -| SessionDiagnostics | `/dashboard/session-diagnostics` | User, current chain and active session details | -| DemoStorePage | `/dashboard/demo-store` | User, active sandbox, no demo role grants | -| AuthorizationLab | `/dashboard/authorization-lab` | User, real navigation index | -| Error | `/Error` | No HTTP request context | -| PublicPage | `/dashboard/auth/public` | Anonymous sample | -| AuthenticatedPage | `/dashboard/auth/authenticated` | User sample | -| AdminRolePage | `/dashboard/auth/admin` | Admin sample role | -| AdminPermissionPage | `/dashboard/auth/permission-admin` | Sample Admin plus query permission | -| AspNetAuthorizePage | `/dashboard/auth/aspnet` | User, ASP.NET authentication cascade | -| CombinedAuthorizationPage | `/dashboard/auth/combined` | Sample Admin plus query permission | -| StateViewPage | `/dashboard/auth/state-view` | Real shared state-view examples | -| MatchModesPage | `/dashboard/auth/match-modes` | Real shared match-mode examples | - -Credentials have no separate route; their section belongs to MyIdentityPage. -AdminRepairPanel has no route and is exercised as an actual AdminPage child. -Its scan/repair actions are outside smoke scope. Other non-routable children, -layouts and dialogs are not independently inventoried. - -## Tests and route enforcement - -The original eight test methods / ten cases retain their bodies and InlineData; -only six SmokeTestFor annotations were added to connect their pages to inventory. -Added 18 one-scenario page tests and one route coverage guard: **28 page-render -cases plus one inventory test**, 29 total cases under `Category=Render`. - -SmokeTestFor attributes live on actual enabled xUnit render methods. The guard -reflects only the Identity Lab application assembly for RouteAttribute-bearing -IComponent types. It checks every component, reports all route aliases for missing -components, rejects mappings to non-routable/external types, and verifies mappings -point to enabled Fact/Theory methods. Non-routable children and test/library -assemblies are not route candidates. A page with multiple aliases needs one real -component render test. There are no exception lists or arbitrary dynamic renders. - -The small generic assertion helper renders a statically selected real component. -It checks stable page text and rejects error alerts. StateView/MatchModes samples -are the narrow exception to the error-alert check because denied examples are -intentional content. Application pages additionally assert loaded content where -needed: member display name, profile/credentials, owner name, empty access/activity, -admin table/repair panel, active session and evaluated demo access. - -## Setup - -Reuse the original bUnit/MudBlazor/MudExtensions fixture, real UltimateAuth -authorization evaluator and uniquely named EF InMemory LabDbContext store. -Additional registrations are grouped for management, admin, demo and diagnostics; -external client/store responses are explicit. Real page and Lab service lifecycle -methods remain active. No application host or child stubs are installed. - -The renderer now supplies ASP.NET AuthenticationState as well as UAuthState. -The existing internal snapshot adapter accepts optional permission claims. -Regular application Users do not receive admin permission claims. The sample -routes use their declared sample Admin role/permission as appropriate. -Diagnostics renders a real current-chain/detail response and uses async fixture -disposal for its timer/subscription lifecycle. No new packages or database -dependencies were added in this completion phase. - -## Validation - -Attempted from repository root: - -```sh -git fetch origin dev -dotnet build tests/CodeBeam.UltimateAuth.IdentityLab.Tests/CodeBeam.UltimateAuth.IdentityLab.Tests.csproj -c Release -dotnet test tests/CodeBeam.UltimateAuth.IdentityLab.Tests/CodeBeam.UltimateAuth.IdentityLab.Tests.csproj -c Release --filter Category=Render -``` - -Fetch failed: configured proxy unreachable. Both .NET commands failed before -execution: `dotnet: command not found`. **No build, render test, route guard or -existing service regression test has executed here.** No measured coverage is -reported. - -Completed static checks: - -- Compared and fast-forwarded to available local dev; no divergent commits. -- Matched all 24 source page types/routes to the annotated actual render methods. -- Reviewed component namespaces, cascades, injected service constructors, client - and store method signatures, result constructors and bUnit API usage against - existing repository conventions. This cannot substitute for compilation. -- Parsed project XML and checked added-source whitespace. -- `git diff --check` passed. -- Full patch, including new files, passes `git apply --cached --check` against - an isolated index initialized from `b886b94`. - -On a .NET 10 CI runner, the filtered command above runs the existing behavioral -render cases, new page smoke cases and route guard without Docker, PostgreSQL or a -browser. Run the full Identity Lab project separately with its documented -PostgreSQL/Docker requirements. Existing known-defect tests remain unchanged. - -## Limits - -Compilation and successful rendering remain unverified until .NET execution. -These tests bypass routing, so they do not validate authorization enforcement by -the router. Layout composition, dialogs, mutations, repair scans, populated large -data sets and detailed interaction states remain outside this smoke phase. EF -InMemory does not validate relational constraints, SQL translation, migrations or -transactions. No percentage coverage target or estimate is used. - -## Files - -Completion phase: - -- Rendering/PageRenderContext.cs: identifier client, ASP.NET cascade, optional claims. -- Rendering/PageRenderContext.ApplicationPages.cs: focused dependency/response groups. -- Rendering/PageRenderTests.cs: mappings only; original cases preserved. -- Rendering/RemainingPageRenderTests.cs: 18 additional real page smoke tests. -- Rendering/RouteSmokeCoverageTests.cs: metadata mapping and route guard. -- README.md and this report: inventory, commands and validation limits. - -The prior phase's project dependencies remain bunit 2.11.3 and EF InMemory 10.0.12. -No production code, migration, CI configuration or service test was edited. diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/security-coverage.md b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/security-coverage.md deleted file mode 100644 index 9f6f5f98..00000000 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/security-coverage.md +++ /dev/null @@ -1,48 +0,0 @@ -# Inspection and security coverage matrix - -## Architecture and trust boundaries - -Identity Lab is a .NET 10 interactive Blazor Server application. Framework entities use UAuthDbContext; sandbox, membership, settings and activity entities use LabDbContext, on PostgreSQL. These are separate persistence units. Registration is a multi-step orchestration, not one transaction across both contexts. - -`SandboxUAuthAccessPolicy` constrains framework users, credentials, sessions, roles and flows. It queries membership/status and validates single and batch targets. `LabApplicationPolicy` blocks public role mutation even for Ladmin. Policies compose with framework policies: an Allow from one policy is not evidence that the entire operation succeeds. - -`LabDemoRoleService` resolves authenticated context internally, requires persisted active ownership, restricts targets to regular members in the same sandbox, and pins Manager/Customer IDs. `LabUserProvisioningService` additionally compares its actor argument to the authenticated actor. Internal reference tokens protect account creation and narrowly scoped rollback deletion. `LabRoleService` is a trusted store helper; it is not registered as a public mutation endpoint. - -`LabAdminGuard` rechecks fixed-ID roles and assignments in stores. `/api/lab/admin` applies an authorization filter and its services also use the guard. `LabRootResolver`, unlike the guard, consumes typed role IDs from server-generated context attributes. Context production and transport tamper resistance therefore need separate integration coverage. - -`SandboxService`, repair services and some other application methods accept user keys as trusted caller inputs. Direct invocation tests establish their database behavior; they do not establish that every Blazor or HTTP caller supplies the authenticated identity. UI visibility was not counted as authorization. - -Admin seeding verifies fixed system role IDs, prevents identifier takeover and assigns Ladmin to the configured fixed identity. Demo seeding pins IDs and reconciles permission sets. The database has unique owner and membership indexes, a filtered unique owner-membership index, foreign keys and cascading membership/settings deletion. Membership capacity changes use serializable transactions. - -Existing tests use xUnit 2.9.3, Test SDK 17.14.1 and net10.0 for EF integration; those tests use SQLite. This project follows their xUnit/SDK conventions but uses PostgreSQL for provider-specific constraints, migrations, scope queries and transaction behavior. It adds no duplicate framework CRUD test suite. - -## Matrix - -The implemented tests have been compiled and exercised against PostgreSQL 17. The execution report records final counts and the deliberately preserved failing regression. - -| Priority | Boundary | Test area | Status and limits | -|---|---|---|---| -| P0 | Public Owner/User/Ladmin role mutation | Security/RolePolicyTests | Implemented policy and actual framework role-service denials, stored assignments unchanged, authenticated reads and system positive control; no HTTP dispatch | -| P0 | Demo operations cannot assign system roles | Services/DemoRoleServiceTests | Implemented hostile names, owner/member scope, inactive status, wrong fixed ID, legitimate assignment; focused role stores mocked; full-persistence integration also covers legitimate Manager assignment and denied Ladmin assignment | -| P0 | Forged admin role attributes | Security/AdminGuardTests, RolePolicyTests; Integration/RegistrationIntegrationTests | Implemented persisted-assignment guard and typed-ID root behavior, plus real context-factory replacement of forged typed Ladmin attributes with stored Owner identity; HTTP/cookie tampering untested | -| P0 | Foreign/missing targets and sessions | Authorization/SandboxPolicyTests | Implemented read/update/delete/session/role decisions and positive controls, both owners/members; no real session rows | -| P0 | Batch targets, owner deletion, inactive sandbox | Authorization/SandboxPolicyTests | Implemented policy checks against PostgreSQL | -| P0 | Spoofed actor during provisioning | Provisioning/MemberProvisioningTests | Implemented mismatched actor and regular-member denials before framework calls | -| P0 | Provisioning token forgery | Provisioning/ProvisioningPolicyTests | Implemented bool/string impostors rejected by actual policy | -| P0 | Fault rollback and token reuse | Provisioning/MemberProvisioningTests | Implemented injected assignment failure, exact target, unrelated target, one-shot consumption and post-operation revocation; focused framework deletion mocked; real-account hard deletion after injected role-store failure also covered | -| P0 | Registration relationships | Provisioning/RegistrationTests | Implemented trusted-context handoff, PostgreSQL account/Owner-role/sandbox/membership creation, and absence of Ladmin assignment; both real-persistence and focused mock cases | -| P0 | Relational atomicity/ownership | Integration/SandboxPersistenceTests | Implemented duplicate-owner rollback, concurrent owner uniqueness, no cross-sandbox membership | -| P0 | User capacity contention | Integration/SandboxPersistenceTests | Implemented two independent serializable operations competing for one slot; any scheduling allowed, no sleeps | -| P0 | Deletion membership lifecycle | Services/AccountDeletionTests, Integration/SandboxPersistenceTests | Implemented success/failure ordering, owner restrictions and cross-sandbox preservation; actual framework cleanup untested | -| P0 | Repair scope | Services/RepairTests | Implemented stale-member cleanup restricted to one sandbox, owners preserved; owner/admin repair paths untested | -| P0 | Wrong system role ID | Regression/SystemRoleIdentityTests | Executed and reproduced failing rejection; corrupted store scenario, not demonstrated external exploit | -| P1 | Demo store permissions | Services/DemoStoreTests | Implemented Manager/Customer controls, wildcard/admin filtering, suspended denial; role persistence mocked | -| P1 | Status transitions | Authorization/SandboxPolicyTests | Active/passive/suspended behavior covered; authorized transition APIs untested | -| P1 | Identifier availability/duplicate registration | Integration/RegistrationIntegrationTests | Duplicate registration covered with real framework PostgreSQL composition; standalone availability interface not yet covered | -| P1 | Diagnostics authorization | Authorization/SandboxPolicyTests | Session read policy only; diagnostics UI/endpoint transport untested | -| P1 | Account management | Services/AccountDeletionTests | Partial: deletion ordering only; credentials/status/profile endpoint composition untested | -| P2 | Seed contributor end-to-end repeatability | Integration/RegistrationIntegrationTests | Implemented real PostgreSQL seeding, repeat execution, fixed admin assignment count and exact Manager permissions | -| P2 | Full HTTP/cookie/CSRF/context trust | — | Not implemented | -| P2 | Faults across both real DbContexts and cancellation/restart | Integration/RegistrationIntegrationTests | Real member-account hard-delete rollback after role-store fault covered; registration faults, cancellation and process restart remain untested | - -Passing the implemented tests cannot prove that Ladmin is unreachable through every Identity Lab operation. Highest-priority remaining work is authenticated transport tests for caller-supplied identities, registration failure recovery, and full cleanup/cancellation/restart fault coverage. Both DbContexts and production migrations are exercised, but no test runs the complete hosted HTTP application. diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/service-regression-coverage.md b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/service-regression-coverage.md deleted file mode 100644 index f16016fe..00000000 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/service-regression-coverage.md +++ /dev/null @@ -1,119 +0,0 @@ -# Service regression coverage addition - -## Assessment - -Reviewed SandboxService, SandboxUserService, LabDashboardService, SandboxAdminService, -LabAdminService, SandboxRepairService, LabOwnerRepairService and LabRepairService, -alongside existing provisioning, registration, deletion, role, authorization and -PostgreSQL integration tests. Existing tests already protect creation relationships, -duplicate owners, membership isolation, capacity contention, member provisioning, -rollback, owner deletion denial and basic stale membership cleanup. Those scenarios -were retained without duplication. - -The selected gaps are dashboard membership aggregation and graceful degradation, -capacity-setting effects on subsequent operations, PostgreSQL literal search, -statistics after mutations, and repeatable repairs that preserve valid relationships. - -## Added tests (12 cases across 7 methods) - -| File / group | Observable behavior and regression detected | Priority rationale | -| --- | --- | --- | -| Services/DashboardTests.cs (4 cases) | Own sandbox's persisted membership count; owner/member distinction; configured and absolute capacity limits; remaining capacity floors at zero; counts update after removal; optional profile/session/role failures do not lose the overview; absent membership avoids client calls | Incorrect counts and optional-service outages affect the main application overview | -| Services/SandboxAdminTests.cs (5 cases) | Reject capacity below member count without writes; full sandbox rejects additions and increased capacity allows them; foreign sandbox preserved; `%`, `_` and backslash searches are literal under PostgreSQL; rename trims; status filtering and statistics reflect persisted transitions/removal | Protects application settings and real provider query behavior | -| Services/RepairTests.cs (1 additional case) | Preview is read-only and predicts cleanup; valid members survive alongside owners and foreign members; repeat repair removes nothing and leaves state identical | Detects overbroad cleanup and non-repeatable repair | -| Integration/SandboxPersistenceTests.cs (2 additional cases) | Ensure restores missing or misclassified owner membership in the original sandbox, retains settings/name and other members, and is repeatable | Protects membership recovery against duplicates and replacement of existing sandbox data | - -All database scenarios reuse the existing isolated PostgreSQL fixture and actual -migrations. Framework clients/stores are mocked only at the existing boundary; -no production code, package dependencies or infrastructure abstractions changed. -Dashboard counts intentionally represent membership rows, as the current service -specifies; these tests do not claim that dashboard reads filter deleted accounts. - -## Validation in this workspace - -- `dotnet test tests/CodeBeam.UltimateAuth.IdentityLab.Tests -c Release --logger trx` - could not start: `dotnet: command not found`. -- SDK bootstrap attempted through the inherited proxy; download failed because - `proxy:8080` was unreachable. TLS verification was not disabled. -- The managed local Docker socket health check returned permission denied. -- Tests executed: 0; passed: 0; failed test cases: 0. All 12 added cases remain - uncompiled/unexecuted; no pass claim is made. Broader suite likewise blocked. -- Static review checked client signatures, DTO property names, existing fixture - conventions and expected service behavior. `git diff --check` passed. -- Existing README documents an intentionally failing - `KnownDefect=SystemRoleIdentity` test. It was preserved; this run cannot confirm - its current outcome. Do not interpret historical execution reports as this run. - -Run the command above with .NET 10 and Docker, or a dedicated PostgreSQL instance -via `IDENTITYLAB_TEST_POSTGRES`. Then run the broader solution suite as appropriate. - -## Remaining risks - -- HIGH: SandboxUserService owner-initiated deletion success/failure and membership - cleanup with framework persistence; self-deletion tests cover a different path. -- HIGH: LabOwnerRepairService eligibility/capacity and LabRepairService owner repair, - scan completeness and activity-history-protected empty sandbox deletion. -- HIGH: Existing system-role identity rejection regression documented by the suite; - outside the permitted testability-only production changes. -- MEDIUM: SandboxUserService missing/deleted summaries; real deletion to repair to - dashboard workflow; dashboard successful optional data and cancellation. -- MEDIUM: LabAdminService's separate update/query/statistics workflows, activity - visibility, standalone availability and diagnostics policy composition. -- LOW: Additional query paging/sort combinations and validation message mappings. - -## Changed files - -Added Services/DashboardTests.cs and Services/SandboxAdminTests.cs. Extended -Services/RepairTests.cs and Integration/SandboxPersistenceTests.cs. Added this -report. No production changes. Work was performed on the supplied `work` checkout -at 45dcfcd (which differs from the locally recorded origin/dev); branch history was -not rewritten or reset. - -## Continuation: critical workflows - -The previous 12 cases were preserved. Static source review found their client -signatures, DTO properties and expected membership-based counts consistent with -current services. Their intentionally inconsistent capacity fixtures test defensive -dashboard calculations, rather than claiming those settings can be applied through -the admin UI. They still require compilation and execution. - -Added Integration/UserWorkflowTests.cs: four cases across two parameterized methods. -The deletion variants compose actual registration, provisioning, SandboxUserService, -summary queries, framework lifecycle/deletion services and both PostgreSQL contexts. -They verify creation visibility, membership-based capacity rejection with no rejected -account (including deleted summaries), foreign deletion denial before client access, -owner hard deletion or member soft deletion, membership cleanup, dashboard count, -replacement provisioning and preservation of the foreign sandbox. The IUAuthClient -boundary is adapted with Moq to invoke the real framework application service; -authenticated contexts use the existing test adapter. These are service integration -tests, not hosted HTTP or cookie authentication tests. Optional dashboard calls use -its existing graceful failure path. - -The repair variants invoke LabOwnerRepairService and LabRepairService with actual -lifecycle, summary, role and assignment stores. Admin recovery uses the real -LabAdminGuard with the seeded administrator. They verify missing-owner detection, -persisted restoration alongside valid members, repeat rejection without mutations, -and preservation of another sandbox. Existing Ensure and stale-member repair tests -exercise different repair entry points and were not duplicated. - -Validation command attempted: -`dotnet test tests/CodeBeam.UltimateAuth.IdentityLab.Tests -c Release --logger trx`. -Result: shell exit 127 (`dotnet: command not found`), no tests executed. No blocked -SDK downloads were retried. Focused and broader execution and compilation remain -unavailable, not failed test cases. `git diff --check` passed. Static review checked -constructors, dependency registrations in LabRuntime, async mock return types, -UserQuery/summary APIs, lifecycle IsDeleted and client deletion result types against -source. No production changes were made. A build command for a capable environment -is `dotnet build tests/CodeBeam.UltimateAuth.IdentityLab.Tests -c Release`. - -Before merging, compile and run the entire Identity Lab project with .NET 10 and -PostgreSQL, including these four cases and the original 12. Triage the existing -KnownDefect=SystemRoleIdentity failure separately; it has not been removed or run. -The most important workflows now have test implementations, but reliable regression -protection cannot be claimed until execution succeeds. The additional suite remains -small and reuses existing fixtures without new production abstractions. - -Material remaining gaps: framework deletion failure followed by facade membership -preservation; inactive accounts retaining membership and capacity; repair capacity -rejection/new sandbox recovery and activity-history deletion safeguards. Transport -identity validation remains outside these service integration tests. diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestAccessContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestAccessContext.cs index e11741dc..62be846f 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestAccessContext.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestAccessContext.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore; @@ -19,6 +20,7 @@ public static AccessContext WithAction(string action) targetUserKey: null, resourceTenant: TenantKey.Single, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance ); } @@ -43,6 +45,7 @@ public static AccessContext ForUser( targetUserKey: userKey, resourceTenant: t, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance ); } @@ -68,6 +71,7 @@ public static AccessContext ForTargetUser( targetUserKey: targetUserKey, resourceTenant: t, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance ); } @@ -91,6 +95,7 @@ public static AccessContext ForUserCreation( targetUserKey: null, resourceTenant: t, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance ); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/TestAccessContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/TestAccessContext.cs index 6303151e..d8e80366 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/TestAccessContext.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/TestAccessContext.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; namespace CodeBeam.UltimateAuth.Tests.Integration.Helpers; @@ -19,6 +20,7 @@ public static AccessContext WithAction(string action) targetUserKey: null, resourceTenant: TenantKey.Single, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance ); } @@ -43,6 +45,7 @@ public static AccessContext ForUser( targetUserKey: userKey, resourceTenant: t, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance ); } @@ -68,6 +71,7 @@ public static AccessContext ForTargetUser( targetUserKey: targetUserKey, resourceTenant: t, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance ); } @@ -91,6 +95,7 @@ public static AccessContext ForUserCreation( targetUserKey: null, resourceTenant: t, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance ); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs index af287e0e..119109a7 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs @@ -12,6 +12,7 @@ using Moq; using System.Net; using System.Net.Http.Json; +using CodeBeam.UltimateAuth.Core.Options; namespace CodeBeam.UltimateAuth.Tests.Integration; @@ -597,6 +598,7 @@ await profileFactory targetUserKey: actor, resourceTenant: TenantKeys.Single, action: "test", + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance); // DefaultPageSize = 2 diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordValidationPipelineTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordValidationPipelineTests.cs index 1b8358b6..e6cab3bf 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordValidationPipelineTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordValidationPipelineTests.cs @@ -66,11 +66,11 @@ private static EffectivePasswordPolicyOptions CreateOptions( }); } - private static DefaultPasswordValidationPipeline CreatePipeline( + private static UAuthPasswordValidationPipeline CreatePipeline( RecordingHistoryValidator history, AcceptAllStrengthPolicy? strength = null) { - return new DefaultPasswordValidationPipeline( + return new UAuthPasswordValidationPipeline( strength ?? new AcceptAllStrengthPolicy(), history); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs index d20659d9..2615f72a 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; @@ -19,6 +20,7 @@ public static AccessContext WithAction(string action) targetUserKey: null, resourceTenant: TenantKey.Single, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance ); } @@ -43,6 +45,7 @@ public static AccessContext ForUser( targetUserKey: userKey, resourceTenant: t, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance ); } @@ -68,6 +71,7 @@ public static AccessContext ForTargetUser( targetUserKey: targetUserKey, resourceTenant: t, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance ); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/AccessPoliciesTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/AccessPoliciesTests.cs index 5ce023e3..4889b9a5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/AccessPoliciesTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/AccessPoliciesTests.cs @@ -8,6 +8,7 @@ using CodeBeam.UltimateAuth.Policies; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using AwesomeAssertions; +using CodeBeam.UltimateAuth.Core.Options; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -108,6 +109,7 @@ public async Task Decide_CrossTenant_ShouldDeny() targetUserKey: null, resourceTenant: resourceTenant, action: "users.get.admin", + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance); var result = await sut.DecideAsync(context); @@ -252,6 +254,7 @@ public async Task Decide_SystemActor_ShouldAllow() targetUserKey: null, resourceTenant: TenantKey.Single, action: "users.repair.system", + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance); var result = await sut.DecideAsync(context); @@ -763,6 +766,7 @@ public void AppliesTo_SystemActor_ShouldReturnFalse() targetUserKey: null, resourceTenant: TenantKey.System, action: "users.get.admin", + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance); sut.AppliesTo(context).Should().BeFalse(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs index 472cef8a..48c01bd4 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs @@ -882,6 +882,7 @@ private static AccessContext Context( targetUserKey: user, resourceTenant: TenantKey.Single, action: "session.manage", + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionEndpointHandlerTests.cs index 913aefca..b73999d4 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionEndpointHandlerTests.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Http.HttpResults; using Moq; using System.Text; +using CodeBeam.UltimateAuth.Core.Options; namespace CodeBeam.UltimateAuth.Tests.Unit.Server; @@ -659,8 +660,8 @@ private static AccessContext CreateAccess( targetUserKey: targetUser, resourceTenant: flow.Tenant, action: action, - attributes: - new Dictionary()); + clientProfile: UAuthClientProfile.NotSpecified, + attributes: new Dictionary()); } private static AuthFlowContext CreateUnauthenticatedFlow() diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs index b3b93559..b8cb0db4 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs @@ -1,15 +1,16 @@ -using System.Security.Claims; +using AwesomeAssertions; using CodeBeam.UltimateAuth.Authorization.Contracts; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Policies.Abstractions; using CodeBeam.UltimateAuth.Server.Authorization; using CodeBeam.UltimateAuth.Server.Infrastructure; -using AwesomeAssertions; using Microsoft.AspNetCore.Http; using Moq; +using System.Security.Claims; namespace CodeBeam.UltimateAuth.Tests.Unit.Server; @@ -474,6 +475,7 @@ private static AccessContext CreateContext() targetUserKey: null, resourceTenant: default, action: "users.read", + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs index 05dc040f..d4675dc9 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs @@ -5,6 +5,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Server.Services; @@ -196,6 +197,7 @@ public async Task CreateUserAsync_InvokesLifecycleIntegrations() context.ResourceTenant, It.IsAny(), request, + clientProfile: UAuthClientProfile.NotSpecified, It.IsAny())) .Returns(Task.CompletedTask); @@ -205,6 +207,7 @@ public async Task CreateUserAsync_InvokesLifecycleIntegrations() context.ResourceTenant, It.IsAny(), request, + clientProfile: UAuthClientProfile.NotSpecified, It.IsAny()), Times.Once); } @@ -2555,6 +2558,7 @@ private static AccessContext CreateContext( targetUserKey: targetUserKey ?? actorUserKey, resourceTenant: TenantKey.Single, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs index 97ea6934..02966ea8 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs @@ -1,11 +1,12 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using AwesomeAssertions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; -using AwesomeAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; using Moq; @@ -1993,6 +1994,7 @@ private static AccessContext CreateAccessContext(Fixture f, UserKey? targetUserK targetUserKey: targetUserKey ?? f.Flow.UserKey, resourceTenant: f.Flow.Tenant, action: "test", + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance); } @@ -2021,4 +2023,4 @@ private sealed record Fixture( Mock AccessContextFactory, Mock Users, DefaultHttpContext HttpContext); -} \ No newline at end of file +} From b05a1887ff0a790af5391fe70d31636434b9d92f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 2 Nov 2026 15:23:37 +0300 Subject: [PATCH 10/10] Add Tests --- .../CredentialManagementServiceTests.cs | 58 +++++- .../PasswordPolicyEndpointHandlerTests.cs | 173 ++++++++++++++++++ 2 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PasswordPolicyEndpointHandlerTests.cs diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs index 3dc8cdf5..4c9850f2 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs @@ -12,6 +12,7 @@ using CodeBeam.UltimateAuth.Credentials.Options; using CodeBeam.UltimateAuth.Credentials.Policies; using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.Credentials.Reference.Internal; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; @@ -1118,6 +1119,61 @@ await f.Sut.AddAsync( } + [Fact] + public async Task DeleteInternalAsync_SoftDeletesUserCredentialsWithinAtomicExecution() + { + var f = CreateFixture(); + var tenant = TenantKey.Single; + var user = UserKey.New(); + + var atomicExecutionCalled = false; + + f.AtomicExecutor + .Setup(x => x.ExecuteAsync( + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>( + async (action, ct) => + { + atomicExecutionCalled = true; + await action(ct); + }); + + f.CredentialStore + .Setup(x => x.DeleteByUserAsync( + user, + DeleteMode.Soft, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + var service = (IUserCredentialsInternalService)f.Sut; + + var result = await service.DeleteInternalAsync( + tenant, + user, + CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + atomicExecutionCalled.Should().BeTrue(); + + f.CredentialStoreFactory.Verify( + x => x.Create(tenant), + Times.Once); + + f.CredentialStore.Verify( + x => x.DeleteByUserAsync( + user, + DeleteMode.Soft, + Now, + It.IsAny()), + Times.Once); + + f.HistoryStoreFactory.VerifyNoOtherCalls(); + f.AccessOrchestrator.VerifyNoOtherCalls(); + } + + // --------------------------------------------------------- // Helpers // --------------------------------------------------------- @@ -1412,7 +1468,7 @@ public Fixture() EffectiveOptionsProvider .Setup(x => x.GetEffective( It.IsAny(), - UAuthClientProfile.NotSpecified)) + It.IsAny())) .Returns((TenantKey tenant, UAuthClientProfile profile) => new EffectiveUAuthServerOptions { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PasswordPolicyEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PasswordPolicyEndpointHandlerTests.cs new file mode 100644 index 00000000..971e5f53 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PasswordPolicyEndpointHandlerTests.cs @@ -0,0 +1,173 @@ +using AwesomeAssertions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server; + +public sealed class PasswordPolicyEndpointHandlerTests +{ + [Fact] + public async Task GetAsync_WhenAllowed_ReturnsEffectivePolicyAndNoStore() + { + var f = new Fixture(); + + f.AllowExecution(); + + var result = await f.Sut.GetAsync(f.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().NotBeNull(); + + var info = ok.Value!; + + info.Level.Should().Be( + f.ExpectedPolicy.Level); + info.MinLength.Should().Be( + f.ExpectedPolicy.MinLength); + info.MaxLength.Should().Be( + f.ExpectedPolicy.MaxLength); + + f.HttpContext.Response.Headers.CacheControl + .ToString().Should().Be("no-store"); + + f.VerifyAccessContextCreated(); + f.Orchestrator.Verify( + x => x.ExecuteAsync( + f.AccessContext, + It.IsAny>(), + f.HttpContext.RequestAborted), + Times.Once); + } + + [Fact] + public async Task GetAsync_WhenAccessDenied_DoesNotExecutePolicyCommand() + { + var f = new Fixture(); + + f.Orchestrator + .Setup(x => x.ExecuteAsync( + f.AccessContext, + It.IsAny>(), + It.IsAny())) + .ThrowsAsync(new UnauthorizedAccessException( + "Access denied by policy")); + + var act = () => f.Sut.GetAsync(f.HttpContext); + + await act.Should() + .ThrowAsync(); + + f.VerifyAccessContextCreated(); + + f.HttpContext.Response.Headers.CacheControl + .ToString().Should().BeEmpty(); + } + + [Fact] + public async Task GetAsync_WhenRequestCancelled_PropagatesCancellation() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var f = new Fixture(cts.Token); + + f.AllowExecution(); + + var act = () => f.Sut.GetAsync(f.HttpContext); + + await act.Should() + .ThrowAsync(); + + f.VerifyAccessContextCreated(); + + f.HttpContext.Response.Headers.CacheControl + .ToString().Should().BeEmpty(); + } + + private sealed class Fixture + { + public DefaultHttpContext HttpContext { get; } = new(); + + public AuthFlowContext Flow { get; } = + AuthFlowTestFactory.New(isAuthenticated: false); + + public Mock FlowAccessor { get; } = new(); + public Mock ContextFactory { get; } = new(); + public Mock Orchestrator { get; } = new(); + + public AccessContext AccessContext { get; } + + public UAuthPasswordPolicyInfo ExpectedPolicy { get; } + + public PasswordPolicyEndpointHandler Sut { get; } + + public Fixture(CancellationToken ct = default) + { + HttpContext.RequestAborted = ct; + + AccessContext = TestAccessContext.WithAction( + UAuthActions.Credentials.GetPasswordPolicyAnonymous); + + ExpectedPolicy = + PasswordPolicyOptionsResolver.Resolve( + Flow.Options.Password).ToInfo(); + + FlowAccessor + .SetupGet(x => x.Current) + .Returns(Flow); + + ContextFactory + .Setup(x => x.CreateAsync( + Flow, + UAuthActions.Credentials.GetPasswordPolicyAnonymous, + "credentials", + null, + null, + ct)) + .ReturnsAsync(AccessContext); + + Sut = new PasswordPolicyEndpointHandler( + FlowAccessor.Object, + ContextFactory.Object, + Orchestrator.Object); + } + + public void AllowExecution() + { + Orchestrator + .Setup(x => x.ExecuteAsync( + AccessContext, + It.IsAny>(), + HttpContext.RequestAborted)) + .Returns, + CancellationToken>( + (_, command, ct) => command.ExecuteAsync(ct)); + } + + public void VerifyAccessContextCreated() + { + ContextFactory.Verify( + x => x.CreateAsync( + Flow, + UAuthActions.Credentials.GetPasswordPolicyAnonymous, + "credentials", + null, + null, + HttpContext.RequestAborted), + Times.Once); + } + } +}