From 00619b3196533dbcda79b875c6fd9777611b98d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sun, 4 Oct 2026 22:48:39 +0300 Subject: [PATCH 01/16] EfCore Integration Tests --- UltimateAuth.slnx | 1 + .../AssemblyVisibility.cs | 1 + ...timateAuth.Tests.Integration.EfCore.csproj | 28 ++++ .../ServiceCollectionTestExtensions.cs | 46 +++++++ .../Infrastructure/EfCoreTestRuntime.cs | 120 +++++++++++++++++ .../FailingUserIdentifierStore.cs | 102 +++++++++++++++ .../FailingUserIdentifierStoreFactory.cs | 26 ++++ .../Infrastructure/IntegrationTestClock.cs | 48 +++++++ .../Infrastructure/TestAccessContext.cs | 93 +++++++++++++ .../Infrastructure/TestUsers.cs | 9 ++ .../UserIdentifierStoreFaultState.cs | 51 ++++++++ .../UserCreationAtomicityTests.cs | 122 ++++++++++++++++++ .../ServiceCollectionTestExtensions.cs | 33 ++--- 13 files changed, 656 insertions(+), 24 deletions(-) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/CodeBeam.UltimateAuth.Tests.Integration.EfCore.csproj create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Extensions/ServiceCollectionTestExtensions.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/EfCoreTestRuntime.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/FailingUserIdentifierStore.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/FailingUserIdentifierStoreFactory.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/IntegrationTestClock.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestAccessContext.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestUsers.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/UserIdentifierStoreFaultState.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserCreationAtomicityTests.cs diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 9bef746e..5f659632 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -26,6 +26,7 @@ + diff --git a/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs index b346c2c9..284fd414 100644 --- a/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs +++ b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs @@ -9,3 +9,4 @@ [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore")] [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Integration")] +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Integration.EfCore")] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/CodeBeam.UltimateAuth.Tests.Integration.EfCore.csproj b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/CodeBeam.UltimateAuth.Tests.Integration.EfCore.csproj new file mode 100644 index 00000000..c5f95081 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/CodeBeam.UltimateAuth.Tests.Integration.EfCore.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Extensions/ServiceCollectionTestExtensions.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Extensions/ServiceCollectionTestExtensions.cs new file mode 100644 index 00000000..abc023d8 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Extensions/ServiceCollectionTestExtensions.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore; + +internal static class ServiceCollectionTestExtensions +{ + public static void DecorateForTest(this IServiceCollection services, Func decorator) where TService : class + { + var descriptor = services.LastOrDefault(x => x.ServiceType == typeof(TService)); + + if (descriptor is null) + { + throw new InvalidOperationException($"Service '{typeof(TService).FullName}' is not registered."); + } + + services.Remove(descriptor); + + services.Add( + ServiceDescriptor.Describe(typeof(TService), + sp => + { + var inner = CreateInstance(sp, descriptor); + + return decorator(sp, inner); + }, + descriptor.Lifetime)); + } + + private static TService CreateInstance(IServiceProvider serviceProvider, ServiceDescriptor descriptor) where TService : class + { + if (descriptor.ImplementationInstance is TService instance) + return instance; + + if (descriptor.ImplementationFactory is not null) + { + return (TService)descriptor.ImplementationFactory(serviceProvider); + } + + if (descriptor.ImplementationType is not null) + { + return (TService)ActivatorUtilities.CreateInstance(serviceProvider,descriptor.ImplementationType); + } + + throw new InvalidOperationException($"Unable to construct decorated service '{typeof(TService).FullName}'."); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/EfCoreTestRuntime.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/EfCoreTestRuntime.cs new file mode 100644 index 00000000..013bb234 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/EfCoreTestRuntime.cs @@ -0,0 +1,120 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using CodeBeam.UltimateAuth.EntityFrameworkCore.Extensions; +using CodeBeam.UltimateAuth.Server.Extensions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore; + +internal sealed class EfCoreTestRuntime : IAsyncDisposable +{ + private readonly SqliteConnection _connection; + + public IServiceProvider Services { get; } + + public IntegrationTestClock Clock { get; } + + private EfCoreTestRuntime( + SqliteConnection connection, + IServiceProvider services, + IntegrationTestClock clock) + { + _connection = connection; + Services = services; + Clock = clock; + } + + public static async Task CreateAsync( + Action? configureServices = null) + { + var connection = + new SqliteConnection("Data Source=:memory:"); + + await connection.OpenAsync(); + + var services = new ServiceCollection(); + + services.AddLogging(); + + // AddUltimateAuthServer registers ASP.NET Core authorization services. + // The test runtime therefore also needs the routing infrastructure + // normally supplied by WebApplication. + services.AddRouting(); + + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + + services.AddSingleton(configuration); + + services + .AddUltimateAuthServer() + .AddUltimateAuthEntityFrameworkCore(db => + { + db.UseSqlite(connection); + }); + + // + // Replace the production clock with a deterministic test clock. + // + var clock = new IntegrationTestClock(); + + services.RemoveAll(); + services.AddSingleton(clock); + + // + // Apply fault injection / test-specific overrides last. + // + configureServices?.Invoke(services); + + var provider = + services.BuildServiceProvider( + new ServiceProviderOptions + { + ValidateScopes = true, + ValidateOnBuild = true + }); + + var runtime = new EfCoreTestRuntime(connection, provider, clock); + + try + { + await runtime.InitializeDatabaseAsync(); + + return runtime; + } + catch + { + await runtime.DisposeAsync(); + throw; + } + } + + private async Task InitializeDatabaseAsync() + { + await using var scope = + Services.CreateAsyncScope(); + + var db = + scope.ServiceProvider + .GetRequiredService(); + + await db.Database.EnsureCreatedAsync(); + } + + public async ValueTask DisposeAsync() + { + if (Services is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync(); + } + else if (Services is IDisposable disposable) + { + disposable.Dispose(); + } + + await _connection.DisposeAsync(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/FailingUserIdentifierStore.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/FailingUserIdentifierStore.cs new file mode 100644 index 00000000..dfd5b153 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/FailingUserIdentifierStore.cs @@ -0,0 +1,102 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore; + +internal sealed class FailingUserIdentifierStore : IUserIdentifierStore +{ + private readonly IUserIdentifierStore _inner; + private readonly UserIdentifierStoreFaultState _fault; + + public FailingUserIdentifierStore(IUserIdentifierStore inner, UserIdentifierStoreFaultState fault) + { + _inner = inner; + _fault = fault; + } + + public Task GetAsync( + Guid key, + CancellationToken ct = default) + => _inner.GetAsync(key, ct); + + public Task ExistsAsync( + Guid key, + CancellationToken ct = default) + => _inner.ExistsAsync(key, ct); + + public async Task AddAsync( + UserIdentifier entity, + CancellationToken ct = default) + { + if (_fault.ShouldFail(entity)) + { + throw new InvalidOperationException( + "simulated_identifier_store_failure"); + } + + await _inner.AddAsync(entity, ct); + } + + public Task SaveAsync( + UserIdentifier entity, + long expectedVersion, + CancellationToken ct = default) + => _inner.SaveAsync(entity, expectedVersion, ct); + + public Task DeleteAsync( + Guid key, + long expectedVersion, + DeleteMode deleteMode, + DateTimeOffset now, + CancellationToken ct = default) + => _inner.DeleteAsync( + key, + expectedVersion, + deleteMode, + now, + ct); + + public Task ExistsAsync( + IdentifierExistenceQuery query, + CancellationToken ct = default) + => _inner.ExistsAsync(query, ct); + + public Task> GetByUserAsync( + UserKey userKey, + CancellationToken ct = default) + => _inner.GetByUserAsync(userKey, ct); + + public Task GetByIdAsync( + Guid id, + CancellationToken ct = default) + => _inner.GetByIdAsync(id, ct); + + public Task GetAsync( + UserIdentifierType type, + string value, + CancellationToken ct = default) + => _inner.GetAsync(type, value, ct); + + public Task> QueryAsync( + UserIdentifierQuery query, + CancellationToken ct = default) + => _inner.QueryAsync(query, ct); + + public Task> GetByUsersAsync( + IReadOnlyList userKeys, + CancellationToken ct = default) + => _inner.GetByUsersAsync(userKeys, ct); + + public Task DeleteByUserAsync( + UserKey userKey, + DeleteMode mode, + DateTimeOffset deletedAt, + CancellationToken ct = default) + => _inner.DeleteByUserAsync( + userKey, + mode, + deletedAt, + ct); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/FailingUserIdentifierStoreFactory.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/FailingUserIdentifierStoreFactory.cs new file mode 100644 index 00000000..35d14694 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/FailingUserIdentifierStoreFactory.cs @@ -0,0 +1,26 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore; + +internal sealed class FailingUserIdentifierStoreFactory + : IUserIdentifierStoreFactory +{ + private readonly IUserIdentifierStoreFactory _inner; + private readonly UserIdentifierStoreFaultState _fault; + + public FailingUserIdentifierStoreFactory( + IUserIdentifierStoreFactory inner, + UserIdentifierStoreFaultState fault) + { + _inner = inner; + _fault = fault; + } + + public IUserIdentifierStore Create(TenantKey tenant) + { + return new FailingUserIdentifierStore( + _inner.Create(tenant), + _fault); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/IntegrationTestClock.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/IntegrationTestClock.cs new file mode 100644 index 00000000..da1af454 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/IntegrationTestClock.cs @@ -0,0 +1,48 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore; + +public sealed class IntegrationTestClock : IClock +{ + private readonly object _sync = new(); + + private DateTimeOffset _utcNow = new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero); + + public DateTimeOffset UtcNow + { + get + { + lock (_sync) + { + return _utcNow; + } + } + } + + public void Advance(TimeSpan duration) + { + if (duration < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(duration)); + + lock (_sync) + { + _utcNow = _utcNow.Add(duration); + } + } + + public void Set(DateTimeOffset value) + { + lock (_sync) + { + _utcNow = value.ToUniversalTime(); + } + } + + public void Reset() + { + lock (_sync) + { + _utcNow = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestAccessContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestAccessContext.cs new file mode 100644 index 00000000..89d3d961 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestAccessContext.cs @@ -0,0 +1,93 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore; + +internal static class TestAccessContext +{ + public static AccessContext WithAction(string action) + { + return new AccessContext( + actorUserKey: null, + actorTenant: TenantKey.Single, + isAuthenticated: false, + isSystemActor: false, + actorChainId: null, + resource: "test", + targetUserKey: null, + resourceTenant: TenantKey.Single, + action: action, + attributes: EmptyAttributes.Instance + ); + } + + public static AccessContext ForUser( + UserKey userKey, + string action, + TenantKey? tenant = null, + SessionChainId? actorChainId = null, + string resource = "identifier") + { + var t = tenant ?? TenantKey.Single; + + return new AccessContext( + actorUserKey: userKey, + actorTenant: t, + isAuthenticated: true, + isSystemActor: false, + actorChainId: actorChainId, + resource: resource, + targetUserKey: userKey, + resourceTenant: t, + action: action, + attributes: EmptyAttributes.Instance + ); + } + + public static AccessContext ForTargetUser( + UserKey actorUserKey, + UserKey targetUserKey, + string action, + TenantKey? tenant = null, + SessionChainId? actorChainId = null, + string resource = "identifier") + { + var t = tenant ?? TenantKey.Single; + + return new AccessContext( + actorUserKey: actorUserKey, + actorTenant: t, + isAuthenticated: true, + isSystemActor: false, + actorChainId: actorChainId, + resource: resource, + targetUserKey: targetUserKey, + resourceTenant: t, + action: action, + attributes: EmptyAttributes.Instance + ); + } + + public static AccessContext ForUserCreation( + UserKey actorUserKey, + string action, + TenantKey? tenant = null, + SessionChainId? actorChainId = null) + { + var t = tenant ?? TenantKey.Single; + + return new AccessContext( + actorUserKey: actorUserKey, + actorTenant: t, + isAuthenticated: true, + isSystemActor: false, + actorChainId: actorChainId, + resource: "users", + targetUserKey: null, + resourceTenant: t, + action: action, + attributes: EmptyAttributes.Instance + ); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestUsers.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestUsers.cs new file mode 100644 index 00000000..fa5385ae --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestUsers.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore; + +public static class TestUsers +{ + public static readonly UserKey Admin = UserKey.FromGuid(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")); + public static readonly UserKey User = UserKey.FromGuid(Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/UserIdentifierStoreFaultState.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/UserIdentifierStoreFaultState.cs new file mode 100644 index 00000000..04ba0f21 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/UserIdentifierStoreFaultState.cs @@ -0,0 +1,51 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore; + +internal sealed class UserIdentifierStoreFaultState +{ + private int _addAttempts; + + public bool Enabled { get; private set; } + + public int FailOnAddAttempt { get; private set; } + + public int AddAttempts => _addAttempts; + + public UserIdentifierType? LastAttemptedType { get; private set; } + + public UserKey? LastAttemptedUserKey { get; private set; } + + public void Enable(int failOnAddAttempt) + { + if (failOnAddAttempt <= 0) + throw new ArgumentOutOfRangeException(nameof(failOnAddAttempt)); + + _addAttempts = 0; + LastAttemptedType = null; + LastAttemptedUserKey = null; + + FailOnAddAttempt = failOnAddAttempt; + Enabled = true; + } + + public void Disable() + { + Enabled = false; + } + + public bool ShouldFail(UserIdentifier identifier) + { + if (!Enabled) + return false; + + var attempt = Interlocked.Increment(ref _addAttempts); + + LastAttemptedType = identifier.Type; + LastAttemptedUserKey = identifier.UserKey; + + return attempt == FailOnAddAttempt; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserCreationAtomicityTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserCreationAtomicityTests.cs new file mode 100644 index 00000000..486a598b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserCreationAtomicityTests.cs @@ -0,0 +1,122 @@ +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore.Users; + +public sealed class UserCreationAtomicityTests +{ + [Fact] + public async Task CreateUser_WhenIdentifierPersistenceFails_ShouldRollbackAllUserState() + { + var fault = + new UserIdentifierStoreFaultState(); + + await using var runtime = + await EfCoreTestRuntime.CreateAsync( + services => + { + services.AddSingleton(fault); + + services.DecorateForTest( + (sp, inner) => new FailingUserIdentifierStoreFactory(inner, sp.GetRequiredService())); + }); + + // Fault injection is enabled only after the runtime and database + // have been fully initialized. + fault.Enable(failOnAddAttempt: 2); + + UserKey userKey; + TenantKey tenant; + + // + // Execute user creation in its own DI scope. + // + using (var scope = runtime.Services.CreateScope()) + { + var service = scope.ServiceProvider.GetRequiredService(); + + var context = TestAccessContext.ForUserCreation(TestUsers.Admin,UAuthActions.Users.CreateAnonymous); + + tenant = context.ResourceTenant; + + var request = + new CreateUserRequest + { + UserName = $"atomic-{Guid.NewGuid():N}", + Email = $"atomic-{Guid.NewGuid():N}@example.com", + FirstName = "Atomic", + LastName = "Failure" + }; + + var exception = + await Assert.ThrowsAsync(() => service.CreateUserAsync(context, request)); + + exception.Message.Should().Be("simulated_identifier_store_failure"); + + fault.AddAttempts.Should().Be(2); + + fault.LastAttemptedType.Should().Be(UserIdentifierType.Email); + + fault.LastAttemptedUserKey.Should().NotBeNull(); + + userKey = fault.LastAttemptedUserKey!.Value; + } + + // + // Verify using a fresh scope / DbContext. + // + // This ensures that the assertions observe persisted database + // state rather than the DbContext change tracker used by the + // failed operation. + // + using (var scope = runtime.Services.CreateScope()) + { + var lifecycleFactory = + scope.ServiceProvider + .GetRequiredService(); + + var profileFactory = + scope.ServiceProvider + .GetRequiredService(); + + var identifierFactory = + scope.ServiceProvider + .GetRequiredService(); + + var lifecycle = + await lifecycleFactory + .Create(tenant) + .GetAsync( + new UserLifecycleKey( + tenant, + userKey)); + + var profiles = + await profileFactory + .Create(tenant) + .GetAllProfilesByUserAsync(userKey); + + var identifiers = + await identifierFactory + .Create(tenant) + .GetByUserAsync(userKey); + + // + // Atomicity contract: + // + // A failed user creation must be observationally equivalent + // to the operation never having occurred. + // + lifecycle.Should().BeNull(); + + profiles.Should().BeEmpty(); + + identifiers.Should().BeEmpty(); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Extensions/ServiceCollectionTestExtensions.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Extensions/ServiceCollectionTestExtensions.cs index f14a31b0..81939a7c 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/Extensions/ServiceCollectionTestExtensions.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Extensions/ServiceCollectionTestExtensions.cs @@ -4,58 +4,43 @@ namespace CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; internal static class ServiceCollectionTestExtensions { - public static void DecorateForTest( - this IServiceCollection services, - Func decorator) - where TService : class + public static void DecorateForTest(this IServiceCollection services, Func decorator) where TService : class { - var descriptor = services.LastOrDefault( - x => x.ServiceType == typeof(TService)); + var descriptor = services.LastOrDefault(x => x.ServiceType == typeof(TService)); if (descriptor is null) { - throw new InvalidOperationException( - $"Service '{typeof(TService).FullName}' is not registered."); + throw new InvalidOperationException($"Service '{typeof(TService).FullName}' is not registered."); } services.Remove(descriptor); services.Add( - ServiceDescriptor.Describe( - typeof(TService), + ServiceDescriptor.Describe(typeof(TService), sp => { - var inner = CreateInstance( - sp, - descriptor); + var inner = CreateInstance(sp, descriptor); return decorator(sp, inner); }, descriptor.Lifetime)); } - private static TService CreateInstance( - IServiceProvider serviceProvider, - ServiceDescriptor descriptor) - where TService : class + private static TService CreateInstance(IServiceProvider serviceProvider, ServiceDescriptor descriptor) where TService : class { if (descriptor.ImplementationInstance is TService instance) return instance; if (descriptor.ImplementationFactory is not null) { - return (TService)descriptor - .ImplementationFactory(serviceProvider); + return (TService)descriptor.ImplementationFactory(serviceProvider); } if (descriptor.ImplementationType is not null) { - return (TService)ActivatorUtilities.CreateInstance( - serviceProvider, - descriptor.ImplementationType); + return (TService)ActivatorUtilities.CreateInstance(serviceProvider,descriptor.ImplementationType); } - throw new InvalidOperationException( - $"Unable to construct decorated service '{typeof(TService).FullName}'."); + throw new InvalidOperationException($"Unable to construct decorated service '{typeof(TService).FullName}'."); } } From 5b5df9e2767f1fe2b4cc11e721a4c168f997bcf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sun, 4 Oct 2026 23:47:34 +0300 Subject: [PATCH 02/16] Fix Same User Creation --- .../User/IdentifierUniquenessResolver.cs | 24 ++++ .../Validator/UserCreateValidator.cs | 60 +++++++++- .../IUserIdentifierAvailabilityService.cs | 2 +- .../Extensions/ServiceCollectonExtensions.cs | 1 + .../Services/UserApplicationService.cs | 3 +- .../UserIdentifierAvailabilityService.cs | 49 ++++++-- .../UserLifecycleTests.cs | 108 ++++++++++-------- .../Users/UserApplicationServiceTests.cs | 4 +- 8 files changed, 193 insertions(+), 58 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/User/IdentifierUniquenessResolver.cs rename src/{users/CodeBeam.UltimateAuth.Users.Reference/Services => CodeBeam.UltimateAuth.Server/Services/Abstractions}/IUserIdentifierAvailabilityService.cs (86%) diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/IdentifierUniquenessResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/IdentifierUniquenessResolver.cs new file mode 100644 index 00000000..5d9b922f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/IdentifierUniquenessResolver.cs @@ -0,0 +1,24 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +// TODO(policy): Move identifier uniqueness decision/enforcement to the Policy layer. +public static class IdentifierUniquenessResolver +{ + public static UniquenessScope GetScope(UAuthServerOptions options, UserIdentifierType type) + { + ArgumentNullException.ThrowIfNull(options); + + var uniqueness = options.Identifiers.Uniqueness; + + return type switch + { + UserIdentifierType.Username => uniqueness.Username, + UserIdentifierType.Email => uniqueness.Email, + UserIdentifierType.Phone => uniqueness.Phone, + _ => uniqueness.Custom + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs index 3d16c55a..79c2bfb9 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs @@ -1,17 +1,20 @@ using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Server.Services; using CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Server.Infrastructure; public sealed class UserCreateValidator : IUserCreateValidator { private readonly IUserIdentifierValidator _identifierValidator; + private readonly IUserIdentifierAvailabilityService _identifierAvailability; private readonly IUserProfileValidator _profileValidator; - public UserCreateValidator(IUserIdentifierValidator identifierValidator, IUserProfileValidator profileValidator) + public UserCreateValidator(IUserIdentifierValidator identifierValidator, IUserIdentifierAvailabilityService identifierAvailability, IUserProfileValidator profileValidator) { _identifierValidator = identifierValidator; + _identifierAvailability = identifierAvailability; _profileValidator = profileValidator; } @@ -35,6 +38,23 @@ public async Task ValidateAsync(AccessContext context }, ct); errors.AddRange(r.Errors); + + if (r.IsValid) + { + var availability = await _identifierAvailability.CheckAsync( + context, + new CheckUserIdentifierAvailabilityRequest + { + Type = UserIdentifierType.Username, + Value = request.UserName + }, + ct); + + if (!availability.IsAvailable) + { + errors.Add(new UAuthValidationError("username_unavailable", "username")); + } + } } if (!string.IsNullOrWhiteSpace(request.Email)) @@ -46,6 +66,23 @@ public async Task ValidateAsync(AccessContext context }, ct); errors.AddRange(r.Errors); + + if (r.IsValid) + { + var availability = await _identifierAvailability.CheckAsync( + context, + new CheckUserIdentifierAvailabilityRequest + { + Type = UserIdentifierType.Email, + Value = request.Email + }, + ct); + + if (!availability.IsAvailable) + { + errors.Add(new UAuthValidationError("email_unavailable", "email")); + } + } } if (!string.IsNullOrWhiteSpace(request.Phone)) @@ -57,6 +94,25 @@ public async Task ValidateAsync(AccessContext context }, ct); errors.AddRange(r.Errors); + + if (r.IsValid) + { + // TODO: CheckAsync also validates identifiers, make them effective. + // TODO: This guard doesn't work with concurrent requests. + var availability = await _identifierAvailability.CheckAsync(context, + new CheckUserIdentifierAvailabilityRequest + { + Type = UserIdentifierType.Phone, + Value = request.Phone + }, + ct); + + if (!availability.IsAvailable) + { + errors.Add( + new UAuthValidationError("phone_unavailable", "phone")); + } + } } var effectiveDisplayName = diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierAvailabilityService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUserIdentifierAvailabilityService.cs similarity index 86% rename from src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierAvailabilityService.cs rename to src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUserIdentifierAvailabilityService.cs index 07f2e961..9e6cf4d8 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierAvailabilityService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUserIdentifierAvailabilityService.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Users.Contracts; -namespace CodeBeam.UltimateAuth.Users.Reference; +namespace CodeBeam.UltimateAuth.Server.Services; public interface IUserIdentifierAvailabilityService { diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs index 8dedbff7..ea1a0174 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using CodeBeam.UltimateAuth.Server.Services; namespace CodeBeam.UltimateAuth.Users.Reference.Extensions; public static class ServiceCollectionExtensions diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 08ab97cc..eff28d65 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -8,6 +8,7 @@ using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users; using Microsoft.Extensions.Options; +using CodeBeam.UltimateAuth.Server.Services; namespace CodeBeam.UltimateAuth.Users.Reference; @@ -529,7 +530,6 @@ public async Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifie if (userScopeResult.Exists) throw new UAuthIdentifierConflictException("identifier_already_exists_for_user"); - // TODO(policy): Move identifier uniqueness decision/enforcement to the Policy layer. await EnsureIdentifierUniquenessAsync(identifierStore, request.Type, normalized.Normalized, userKey, excludeIdentifierId: null, innerCt); if (request.IsPrimary) @@ -995,6 +995,7 @@ private async Task EnsureIdentifierUniquenessAsync( } } + // TODO(policy): Move identifier uniqueness decision/enforcement to the Policy layer. private UniquenessScope GetUniquenessScope(UserIdentifierType type) { var uniqueness = _options.Identifiers.Uniqueness; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserIdentifierAvailabilityService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserIdentifierAvailabilityService.cs index 19afc1ff..7778eed5 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserIdentifierAvailabilityService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserIdentifierAvailabilityService.cs @@ -1,19 +1,24 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.Extensions.Options; public sealed class UserIdentifierAvailabilityService : IUserIdentifierAvailabilityService { private readonly IUserIdentifierValidator _validator; private readonly IIdentifierNormalizer _normalizer; private readonly IUserIdentifierStoreFactory _storeFactory; + private readonly UAuthServerOptions _options; - public UserIdentifierAvailabilityService(IUserIdentifierValidator validator, IIdentifierNormalizer normalizer, IUserIdentifierStoreFactory storeFactory) + public UserIdentifierAvailabilityService(IUserIdentifierValidator validator, IIdentifierNormalizer normalizer, IUserIdentifierStoreFactory storeFactory, IOptions options) { _validator = validator; _normalizer = normalizer; _storeFactory = storeFactory; + _options = options.Value; } public async Task CheckAsync(AccessContext context, CheckUserIdentifierAvailabilityRequest request, CancellationToken ct = default) @@ -42,14 +47,44 @@ public async Task CheckAsync(AccessContext con }); } + var uniquenessScope = IdentifierUniquenessResolver.GetScope(_options, request.Type); + + if (uniquenessScope is UniquenessScope.None or UniquenessScope.WithinUser) + { + if (context.TargetUserKey is null) + { + return UserIdentifierAvailabilityResult.Available(normalized.Normalized); + } + } + var store = _storeFactory.Create(context.ResourceTenant); - var existence = await store.ExistsAsync( - new IdentifierExistenceQuery( - request.Type, - normalized.Normalized, - IdentifierExistenceScope.TenantAny), - ct); + var query = uniquenessScope switch + { + UniquenessScope.Tenant => + new IdentifierExistenceQuery( + request.Type, + normalized.Normalized, + IdentifierExistenceScope.TenantAny), + + UniquenessScope.WithinUser => + new IdentifierExistenceQuery( + request.Type, + normalized.Normalized, + IdentifierExistenceScope.WithinUser, + context.TargetUserKey), + + UniquenessScope.None => + new IdentifierExistenceQuery( + request.Type, + normalized.Normalized, + IdentifierExistenceScope.WithinUser, + context.TargetUserKey), + + _ => throw new InvalidOperationException($"Unsupported uniqueness scope '{uniquenessScope}'.") + }; + + var existence = await store.ExistsAsync(query, ct); return existence.Exists ? UserIdentifierAvailabilityResult.Unavailable(normalized.Normalized) diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs index b89955c0..05b18a09 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; @@ -392,68 +393,85 @@ await CreateUserAsync( .BeTrue(); } - //[Fact] - //public async Task CreateUser_WithDuplicateUsername_ShouldNotCreateSecondUser() - //{ - // _factory.Clock.Reset(); + [Fact] + public async Task CreateUser_WithExistingUsername_ShouldNotCreateSecondUser() + { + _factory.Clock.Reset(); - // using var client = CreateClient(); + using var client = CreateClient(); - // var username = - // $"duplicate-{Guid.NewGuid():N}"; + var username = $"duplicate-{Guid.NewGuid():N}"; - // var first = - // await CreateUserResponseAsync( - // client, - // username); + // First registration succeeds. + var firstResponse = await CreateUserResponseAsync(client, username); - // first.StatusCode.Should() - // .Be(HttpStatusCode.OK); + firstResponse.StatusCode.Should() + .Be(HttpStatusCode.OK); - // var second = - // await CreateUserResponseAsync( - // client, - // username); + var firstResult = + await firstResponse.Content + .ReadFromJsonAsync(); - // var secondResult = await second.Content.ReadFromJsonAsync(); + firstResult.Should().NotBeNull(); + firstResult!.Succeeded.Should().BeTrue(); - // secondResult.Should().NotBeNull(); + var firstUserKey = + GetUserKey(firstResult); - // secondResult!.Succeeded.Should() - // .BeFalse(); + // + // Second sequential registration with the same username + // must be rejected. + // + var secondResponse = + await CreateUserResponseAsync( + client, + username); - // secondResult.FailureReason.Should() - // .NotBeNullOrWhiteSpace(); + secondResponse.IsSuccessStatusCode.Should().BeFalse("creating a user with an identifier already owned by another user must be rejected"); + ((int)secondResponse.StatusCode).Should().BeInRange(400, 499); - // second.IsSuccessStatusCode.Should().BeFalse(); + var problem = await secondResponse.Content.ReadFromJsonAsync(); - // // Verify the important invariant: - // // only one active identifier owns this username. - // using var scope = _factory.Services.CreateScope(); + problem.Should().NotBeNull(); - // var factory = - // scope.ServiceProvider - // .GetRequiredService(); + problem!.Status.Should().Be((int)secondResponse.StatusCode); - // var store = - // factory.Create(TenantKeys.Single); + // + // Verify ownership did not change. + // + using var scope = + _factory.Services.CreateScope(); - // var normalized = - // scope.ServiceProvider - // .GetRequiredService() - // .Normalize( - // UserIdentifierType.Username, - // username); + var identifierFactory = + scope.ServiceProvider + .GetRequiredService(); - // var identifier = - // await store.GetAsync( - // UserIdentifierType.Username, - // normalized.Normalized); + var normalizer = + scope.ServiceProvider + .GetRequiredService(); + + var store = + identifierFactory.Create(TenantKeys.Single); - // identifier.Should().NotBeNull(); - // identifier!.IsDeleted.Should().BeFalse(); - //} + var normalized = + normalizer.Normalize( + UserIdentifierType.Username, + username); + + var identifier = + await store.GetAsync( + UserIdentifierType.Username, + normalized.Normalized); + + identifier.Should().NotBeNull(); + + identifier!.UserKey.Should().Be( + firstUserKey, + "the original user must remain the owner of the username"); + + identifier.IsDeleted.Should().BeFalse(); + } [Fact] public async Task CreateUser_ResultUserKey_ShouldMatchPersistedAggregate() diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs index 3ed0280a..0c2a902a 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.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.Defaults; using CodeBeam.UltimateAuth.Core.Domain; @@ -7,6 +6,7 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; using CodeBeam.UltimateAuth.Users; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; From 990a79694a7803b3560cd5e5d23570e4c865ca6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 5 Oct 2026 00:00:33 +0300 Subject: [PATCH 03/16] Remove Comments on Turkish --- .../Contracts/Refresh/RefreshTokenPersistence.cs | 7 ++----- .../EfCoreAuthenticationSecurityStateStoreContractTests.cs | 3 --- .../Security/Argon2PasswordHasherTest.cs | 2 -- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs index a3cea858..332e0e34 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs @@ -3,15 +3,12 @@ public enum RefreshTokenPersistence { /// - /// Refresh token store'a yazılır. - /// Login, first-issue gibi normal akışlar için. + /// Refresh token persists to the store. /// Persist = 0, /// - /// Refresh token store'a yazılmaz. - /// Rotation gibi özel akışlarda, - /// caller tarafından kontrol edilir. + /// Refresh token does not persist to the store. /// DoNotPersist = 10 } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/EfCoreAuthenticationSecurityStateStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/EfCoreAuthenticationSecurityStateStoreContractTests.cs index 003f5e51..decc065a 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/EfCoreAuthenticationSecurityStateStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/EfCoreAuthenticationSecurityStateStoreContractTests.cs @@ -72,7 +72,4 @@ public async ValueTask DisposeAsync() await _connection.DisposeAsync(); } } - - // Aynı CreateState / MutateState / AssertMutationPersisted - // implementation'ı. } \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs index 95474a45..e77e60b6 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs @@ -92,7 +92,6 @@ public void Verify_Should_Use_Embedded_Salt_And_Parameters() var hash = hasher.Hash("password123"); - // parametreleri değiştir (simulate config drift) var differentOptions = Options.Create(new Argon2Options { Iterations = 999, @@ -104,7 +103,6 @@ public void Verify_Should_Use_Embedded_Salt_And_Parameters() var differentHasher = new Argon2PasswordHasher(differentOptions); - // 🔥 yine de doğrulamalı var result = differentHasher.Verify(hash, "password123"); result.Should().BeTrue(); From e76a1e587be3ded175659732087fbc7a3f50a632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 5 Oct 2026 12:33:16 +0300 Subject: [PATCH 04/16] Argon2 Tests --- .../Argon2Options.cs | 10 +- .../Argon2PasswordHasher.cs | 6 +- .../Security/Argon2PasswordHasherTest.cs | 190 ++++++++++++++++-- .../Argon2ServiceCollectionExtensionsTest.cs | 88 ++++++++ 4 files changed, 271 insertions(+), 23 deletions(-) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2ServiceCollectionExtensionsTest.cs diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2Options.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2Options.cs index 4f73b643..e0cee43c 100644 --- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2Options.cs +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2Options.cs @@ -3,10 +3,10 @@ public sealed class Argon2Options { // OWASP recommended baseline - public int MemorySizeKb { get; init; } = 64 * 1024; // 64 MB - public int Iterations { get; init; } = 3; - public int Parallelism { get; init; } = Environment.ProcessorCount; + public int MemorySizeKb { get; set; } = 64 * 1024; // 64 MB + public int Iterations { get; set; } = 3; + public int Parallelism { get; set; } = Environment.ProcessorCount; - public int SaltSize { get; init; } = 16; - public int HashSize { get; init; } = 32; + public int SaltSize { get; set; } = 16; + public int HashSize { get; set; } = 32; } diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs index 7e3b7875..f72fa2ae 100644 --- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs @@ -49,11 +49,11 @@ public bool Verify(PasswordHash hash, string secret) !int.TryParse(parts[2], out var parallelism)) return false; - var salt = Convert.FromBase64String(parts[3]); - var expectedHash = Convert.FromBase64String(parts[4]); - try { + var salt = Convert.FromBase64String(parts[3]); + var expectedHash = Convert.FromBase64String(parts[4]); + var argon2 = new Argon2id(Encoding.UTF8.GetBytes(secret)) { Salt = salt, diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs index e77e60b6..ea9342a7 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs @@ -9,12 +9,6 @@ namespace CodeBeam.UltimateAuth.Tests.Unit; public class Argon2PasswordHasherTests { - private Argon2PasswordHasher CreateHasher() - { - var options = Options.Create(new Argon2Options()); - return new Argon2PasswordHasher(options); - } - [Fact] public void Hash_Should_Return_Valid_PasswordHash() { @@ -34,9 +28,7 @@ public void Hash_Should_Return_Valid_PasswordHash() public void Verify_Should_Return_True_For_Correct_Password() { var hasher = CreateHasher(); - var hash = hasher.Hash("password123"); - var result = hasher.Verify(hash, "password123"); result.Should().BeTrue(); @@ -46,9 +38,7 @@ public void Verify_Should_Return_True_For_Correct_Password() public void Verify_Should_Return_False_For_Wrong_Password() { var hasher = CreateHasher(); - var hash = hasher.Hash("password123"); - var result = hasher.Verify(hash, "wrong"); result.Should().BeFalse(); @@ -58,9 +48,7 @@ public void Verify_Should_Return_False_For_Wrong_Password() public void Verify_Should_Return_False_For_Invalid_Format() { var hasher = CreateHasher(); - var invalid = PasswordHash.Create(PasswordAlgorithms.Argon2, "invalid"); - var result = hasher.Verify(invalid, "password"); result.Should().BeFalse(); @@ -89,7 +77,6 @@ public void Hash_Should_Produce_Different_Hashes_For_Same_Password() public void Verify_Should_Use_Embedded_Salt_And_Parameters() { var hasher = CreateHasher(); - var hash = hasher.Hash("password123"); var differentOptions = Options.Create(new Argon2Options @@ -112,7 +99,6 @@ public void Verify_Should_Use_Embedded_Salt_And_Parameters() public void NeedsRehash_Should_Return_True_When_Parameters_Changed() { var hasher = CreateHasher(); - var hash = hasher.Hash("password123"); var differentOptions = Options.Create(new Argon2Options @@ -135,11 +121,185 @@ public void NeedsRehash_Should_Return_True_When_Parameters_Changed() public void NeedsRehash_Should_Return_False_When_Parameters_Match() { var hasher = CreateHasher(); + var hash = hasher.Hash("password123"); + var result = hasher.NeedsRehash(hash); + + result.Should().BeFalse(); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void Hash_Should_Throw_When_Password_Is_Null_Or_Empty( + string? password) + { + var hasher = CreateHasher(); + var act = () => hasher.Hash(password!); + act.Should().Throw(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(" ")] + [InlineData(null)] + public void Verify_Should_Return_False_When_Secret_Is_Invalid( + string? secret) + { + var hasher = CreateHasher(); var hash = hasher.Hash("password123"); + var result = hasher.Verify(hash, secret!); - var result = hasher.NeedsRehash(hash); + result.Should().BeFalse(); + } + + [Fact] + public void Verify_Should_Return_False_When_Algorithm_Is_Not_Argon2() + { + var hasher = CreateHasher(); + + var hash = PasswordHash.Create("different-algorithm", "3.65536.1.c2FsdA==.aGFzaA=="); + + var result = hasher.Verify(hash, "password123"); result.Should().BeFalse(); } + + [Theory] + [InlineData("invalid.65536.1.c2FsdA==.aGFzaA==")] + [InlineData("3.invalid.1.c2FsdA==.aGFzaA==")] + [InlineData("3.65536.invalid.c2FsdA==.aGFzaA==")] + public void Verify_Should_Return_False_When_Parameters_Are_Invalid(string encoded) + { + var hasher = CreateHasher(); + var hash = PasswordHash.Create(PasswordAlgorithms.Argon2, encoded); + var result = hasher.Verify(hash, "password123"); + + result.Should().BeFalse(); + } + + [Theory] + [InlineData("3.65536.1.NOT_BASE64.aGFzaA==")] + [InlineData("3.65536.1.c2FsdA==.NOT_BASE64")] + public void Verify_Should_Return_False_When_Hash_Contains_Invalid_Base64( + string encoded) + { + var hasher = CreateHasher(); + var hash = PasswordHash.Create(PasswordAlgorithms.Argon2, encoded); + var result = hasher.Verify(hash, "password123"); + + result.Should().BeFalse(); + } + + [Fact] + public void NeedsRehash_Should_Return_True_When_Algorithm_Is_Not_Argon2() + { + var hasher = CreateHasher(); + var hash = PasswordHash.Create("different-algorithm", "anything"); + + hasher.NeedsRehash(hash).Should().BeTrue(); + } + + [Theory] + [InlineData("invalid")] + [InlineData("1.2.3")] + [InlineData("1.2.3.4")] + [InlineData("1.2.3.4.5.6")] + public void NeedsRehash_Should_Return_True_When_Format_Is_Invalid( + string encoded) + { + var hasher = CreateHasher(); + + var hash = PasswordHash.Create( + PasswordAlgorithms.Argon2, + encoded); + + hasher.NeedsRehash(hash) + .Should() + .BeTrue(); + } + + [Theory] + [InlineData("invalid.65536.1.c2FsdA==.aGFzaA==")] + [InlineData("3.invalid.1.c2FsdA==.aGFzaA==")] + [InlineData("3.65536.invalid.c2FsdA==.aGFzaA==")] + public void NeedsRehash_Should_Return_True_When_Parameters_Are_Invalid( + string encoded) + { + var hasher = CreateHasher(); + + var hash = PasswordHash.Create( + PasswordAlgorithms.Argon2, + encoded); + + hasher.NeedsRehash(hash) + .Should() + .BeTrue(); + } + + [Fact] + public void NeedsRehash_Should_Return_True_When_Iterations_Changed() + { + var hasher = CreateHasher(); + var hash = hasher.Hash("password123"); + + var differentHasher = CreateHasher(new Argon2Options + { + Iterations = 4 + }); + + differentHasher.NeedsRehash(hash) + .Should() + .BeTrue(); + } + + [Fact] + public void NeedsRehash_Should_Return_True_When_Memory_Size_Changed() + { + var hasher = CreateHasher(); + var hash = hasher.Hash("password123"); + + var differentHasher = CreateHasher(new Argon2Options + { + MemorySizeKb = 32 * 1024 + }); + + differentHasher.NeedsRehash(hash) + .Should() + .BeTrue(); + } + + [Fact] + public void NeedsRehash_Should_Return_True_When_Parallelism_Changed() + { + var hasher = CreateHasher(); + var hash = hasher.Hash("password123"); + + var differentParallelism = + new Argon2Options().Parallelism == 1 + ? 2 + : 1; + + var differentHasher = CreateHasher(new Argon2Options + { + Parallelism = differentParallelism + }); + + differentHasher.NeedsRehash(hash) + .Should() + .BeTrue(); + } + + private static Argon2PasswordHasher CreateHasher() + { + return CreateHasher(new Argon2Options()); + } + + private static Argon2PasswordHasher CreateHasher( + Argon2Options options) + { + return new Argon2PasswordHasher( + Options.Create(options)); + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2ServiceCollectionExtensionsTest.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2ServiceCollectionExtensionsTest.cs new file mode 100644 index 00000000..836b3acf --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2ServiceCollectionExtensionsTest.cs @@ -0,0 +1,88 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Security.Argon2; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class Argon2ServiceCollectionExtensionsTests +{ + [Fact] + public void AddUltimateAuthArgon2_ShouldRegisterPasswordHasher() + { + var services = new ServiceCollection(); + services.AddUltimateAuthArgon2(); + + using var provider = services.BuildServiceProvider(); + + var hasher = provider.GetRequiredService(); + + hasher.Should().BeOfType(); + } + + [Fact] + public void AddUltimateAuthArgon2_WithoutConfiguration_ShouldRegisterDefaultOptions() + { + var services = new ServiceCollection(); + + services.AddUltimateAuthArgon2(); + + using var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService>().Value; + + options.Should().NotBeNull(); + options.Iterations.Should().Be(3); + options.MemorySizeKb.Should().Be(64 * 1024); + options.SaltSize.Should().Be(16); + options.HashSize.Should().Be(32); + } + + [Fact] + public void AddUltimateAuthArgon2_WithConfiguration_ShouldApplyConfiguration() + { + var services = new ServiceCollection(); + + services.AddUltimateAuthArgon2(options => + { + options.Iterations = 7; + options.MemorySizeKb = 32768; + options.Parallelism = 2; + options.SaltSize = 24; + options.HashSize = 48; + }); + + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + options.Iterations.Should().Be(7); + options.MemorySizeKb.Should().Be(32768); + options.Parallelism.Should().Be(2); + options.SaltSize.Should().Be(24); + options.HashSize.Should().Be(48); + } + + [Fact] + public void AddUltimateAuthArgon2_ConfiguredParameters_ShouldBeUsedByHasher() + { + var services = new ServiceCollection(); + + services.AddUltimateAuthArgon2(options => + { + options.Iterations = 4; + options.MemorySizeKb = 16384; + options.Parallelism = 2; + }); + + using var provider = services.BuildServiceProvider(); + var hasher = provider.GetRequiredService(); + var hash = hasher.Hash("Password123!"); + var parts = hash.Hash.Split('.'); + + parts.Should().HaveCount(5); + parts[0].Should().Be("4"); + parts[1].Should().Be("16384"); + parts[2].Should().Be("2"); + } +} From 9f98460962180ab06a02511d9c1b2a054ecd00d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 5 Oct 2026 13:34:19 +0300 Subject: [PATCH 05/16] Policies Tests --- .../Fluent/ConditionalScopeBuilder.cs | 8 + .../Fluent/IPolicyScopeBuilder.cs | 6 +- .../Policies/AccessPoliciesTests.cs | 987 ++++++++++++++++++ .../Policies/PolicyBuilderTests.cs | 390 +++++++ .../Policies/PolicyTests.cs | 331 ++++++ 5 files changed, 1721 insertions(+), 1 deletion(-) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/AccessPoliciesTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/PolicyBuilderTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/PolicyTests.cs diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs index 4c6181ae..c92b5de7 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs @@ -33,4 +33,12 @@ private IPolicyScopeBuilder Add() where TPolicy : IAccessPolicy public IPolicyScopeBuilder RequirePermission() => Add(); public IPolicyScopeBuilder RequireAuthenticated() => Add(); public IPolicyScopeBuilder DenyCrossTenant() => Add(); + + public IConditionalPolicyBuilder When( + Func predicate) + { + ArgumentNullException.ThrowIfNull(predicate); + + return new ConditionalPolicyBuilder(_prefix, context => (_condition(context) == _expected) && predicate(context), _registry, _services); + } } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs index 9f400fae..b51a78db 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Policies; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Policies; public interface IPolicyScopeBuilder { @@ -6,4 +8,6 @@ public interface IPolicyScopeBuilder IPolicyScopeBuilder RequireSelf(); IPolicyScopeBuilder RequirePermission(); IPolicyScopeBuilder DenyCrossTenant(); + + IConditionalPolicyBuilder When(Func predicate); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/AccessPoliciesTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/AccessPoliciesTests.cs new file mode 100644 index 00000000..8d5f79c9 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/AccessPoliciesTests.cs @@ -0,0 +1,987 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Policies; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Policies; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class AccessPoliciesTests +{ + + public sealed class RequireAuthenticatedPolicyTests + { + [Fact] + public void AppliesTo_NormalAction_ShouldReturnTrue() + { + var sut = new RequireAuthenticatedPolicy(); + + var context = TestAccessContext.WithAction("users.get.self"); + + sut.AppliesTo(context).Should().BeTrue(); + } + + [Fact] + public void AppliesTo_AnonymousAction_ShouldReturnFalse() + { + var sut = new RequireAuthenticatedPolicy(); + + var context = TestAccessContext.WithAction("users.create.anonymous"); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Fact] + public void Decide_UnauthenticatedActor_ShouldDeny() + { + var sut = new RequireAuthenticatedPolicy(); + + var context = TestAccessContext.WithAction("users.get.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("unauthenticated"); + } + + [Fact] + public void Decide_AuthenticatedActor_ShouldAllow() + { + var sut = new RequireAuthenticatedPolicy(); + + var context = TestAccessContext.ForUser( + UserKey.New(), + "users.get.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeTrue(); + } + } + + public sealed class DenyCrossTenantPolicyTests + { + [Fact] + public void AppliesTo_ShouldAlwaysReturnTrue() + { + var sut = new DenyCrossTenantPolicy(); + + sut.AppliesTo( + TestAccessContext.WithAction("users.get.self")) + .Should() + .BeTrue(); + } + + [Fact] + public void Decide_SameTenant_ShouldAllow() + { + var sut = new DenyCrossTenantPolicy(); + + var context = TestAccessContext.ForUser( + UserKey.New(), + "users.get.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeTrue(); + } + + [Fact] + public void Decide_CrossTenant_ShouldDeny() + { + var sut = new DenyCrossTenantPolicy(); + + var actorTenant = TenantKey.FromExternal("tenant-a"); + var resourceTenant = TenantKey.FromExternal("tenant-b"); + + var context = new AccessContext( + actorUserKey: UserKey.New(), + actorTenant: actorTenant, + isAuthenticated: true, + isSystemActor: false, + actorChainId: null, + resource: "users", + targetUserKey: null, + resourceTenant: resourceTenant, + action: "users.get.admin", + attributes: EmptyAttributes.Instance); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("cross_tenant_access_denied"); + } + } + + public sealed class RequireSelfPolicyTests + { + [Fact] + public void AppliesTo_SelfAction_ShouldReturnTrue() + { + var sut = new RequireSelfPolicy(); + + var context = + TestAccessContext.WithAction("users.update.self"); + + sut.AppliesTo(context).Should().BeTrue(); + } + + [Theory] + [InlineData("users.update.admin")] + [InlineData("users.update.system")] + [InlineData("users.update")] + public void AppliesTo_NonSelfAction_ShouldReturnFalse(string action) + { + var sut = new RequireSelfPolicy(); + + var context = + TestAccessContext.WithAction(action); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Fact] + public void Decide_UnauthenticatedActor_ShouldDeny() + { + var sut = new RequireSelfPolicy(); + + var context = + TestAccessContext.WithAction("users.update.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("unauthenticated"); + } + + [Fact] + public void Decide_WhenActorIsTarget_ShouldAllow() + { + var sut = new RequireSelfPolicy(); + + var userKey = UserKey.New(); + + var context = + TestAccessContext.ForUser( + userKey, + "users.update.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeTrue(); + result.DenyReason.Should().BeNull(); + } + + [Fact] + public void Decide_WhenActorIsNotTarget_ShouldDeny() + { + var sut = new RequireSelfPolicy(); + + var actor = UserKey.New(); + var target = UserKey.New(); + + var context = + TestAccessContext.ForTargetUser( + actor, + target, + "users.update.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("not_self"); + } + } + + public sealed class RequireSystemPolicyTests + { + [Fact] + public void AppliesTo_SystemAction_ShouldReturnTrue() + { + var sut = new RequireSystemPolicy(); + + var context = + TestAccessContext.WithAction("users.repair.system"); + + sut.AppliesTo(context).Should().BeTrue(); + } + + [Theory] + [InlineData("users.repair.admin")] + [InlineData("users.repair.self")] + [InlineData("users.repair")] + public void AppliesTo_NonSystemAction_ShouldReturnFalse(string action) + { + var sut = new RequireSystemPolicy(); + + var context = + TestAccessContext.WithAction(action); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Fact] + public void Decide_NormalActor_ShouldDeny() + { + var sut = new RequireSystemPolicy(); + + var context = + TestAccessContext.WithAction("users.repair.system"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("system_actor_required"); + } + + [Fact] + public void Decide_SystemActor_ShouldAllow() + { + var sut = new RequireSystemPolicy(); + + var context = new AccessContext( + actorUserKey: null, + actorTenant: TenantKey.System, + isAuthenticated: false, + isSystemActor: true, + actorChainId: null, + resource: "users", + targetUserKey: null, + resourceTenant: TenantKey.Single, + action: "users.repair.system", + attributes: EmptyAttributes.Instance); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeTrue(); + result.DenyReason.Should().BeNull(); + } + + [Fact] + public void AppliesTo_SystemSuffixWithDifferentCasing_ShouldReturnFalse() + { + var sut = new RequireSystemPolicy(); + + var context = + TestAccessContext.WithAction("users.repair.SYSTEM"); + + sut.AppliesTo(context).Should().BeFalse(); + } + } + + public sealed class DenyAdminSelfModificationPolicyTests + { + [Fact] + public void AppliesTo_AdminModificationWithTarget_ShouldReturnTrue() + { + var sut = new DenyAdminSelfModificationPolicy(); + + var context = + TestAccessContext.ForTargetUser( + UserKey.New(), + UserKey.New(), + "users.update.admin"); + + sut.AppliesTo(context).Should().BeTrue(); + } + + [Theory] + [InlineData("users.update.self")] + [InlineData("users.update.system")] + [InlineData("users.update")] + public void AppliesTo_NonAdminAction_ShouldReturnFalse(string action) + { + var sut = new DenyAdminSelfModificationPolicy(); + + var context = + TestAccessContext.ForTargetUser( + UserKey.New(), + UserKey.New(), + action); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Fact] + public void AppliesTo_AdminActionWithoutTarget_ShouldReturnFalse() + { + var sut = new DenyAdminSelfModificationPolicy(); + + var context = + TestAccessContext.WithAction("users.update.admin"); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Theory] + [InlineData("users.get.admin")] + [InlineData("users.read.admin")] + [InlineData("users.query.admin")] + public void AppliesTo_AdminReadAction_ShouldReturnFalse(string action) + { + var sut = new DenyAdminSelfModificationPolicy(); + + var context = + TestAccessContext.ForTargetUser( + UserKey.New(), + UserKey.New(), + action); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Fact] + public void Decide_UnauthenticatedActor_ShouldDeny() + { + var sut = new DenyAdminSelfModificationPolicy(); + + var context = + TestAccessContext.WithAction("users.update.admin"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("unauthenticated"); + } + + [Fact] + public void Decide_AdminModifyingOwnAccount_ShouldDeny() + { + var sut = new DenyAdminSelfModificationPolicy(); + + var userKey = UserKey.New(); + + var context = + TestAccessContext.ForTargetUser( + userKey, + userKey, + "users.update.admin"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should() + .Be("admin_cannot_modify_own_account"); + } + + [Fact] + public void Decide_AdminDeletingOwnAccount_ShouldDeny() + { + var sut = new DenyAdminSelfModificationPolicy(); + + var userKey = UserKey.New(); + + var context = + TestAccessContext.ForTargetUser( + userKey, + userKey, + "users.delete.admin"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should() + .Be("admin_cannot_modify_own_account"); + } + + [Fact] + public void Decide_AdminModifyingDifferentUser_ShouldAllow() + { + var sut = new DenyAdminSelfModificationPolicy(); + + var actor = UserKey.New(); + var target = UserKey.New(); + + var context = + TestAccessContext.ForTargetUser( + actor, + target, + "users.update.admin"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeTrue(); + result.DenyReason.Should().BeNull(); + } + } + + public sealed class ConditionalAccessPolicyTests + { + [Fact] + public void AppliesTo_WhenConditionMatchesExpectedTrue_ShouldReturnTrue() + { + var inner = new TestPolicy(); + + var sut = new ConditionalAccessPolicy( + _ => true, + expected: true, + inner); + + var context = + TestAccessContext.WithAction("users.get.self"); + + sut.AppliesTo(context).Should().BeTrue(); + } + + [Fact] + public void AppliesTo_WhenConditionDoesNotMatchExpectedTrue_ShouldReturnFalse() + { + var inner = new TestPolicy(); + + var sut = new ConditionalAccessPolicy( + _ => false, + expected: true, + inner); + + var context = + TestAccessContext.WithAction("users.get.self"); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Fact] + public void AppliesTo_WhenConditionMatchesExpectedFalse_ShouldReturnTrue() + { + var inner = new TestPolicy(); + + var sut = new ConditionalAccessPolicy( + _ => false, + expected: false, + inner); + + var context = + TestAccessContext.WithAction("users.get.self"); + + sut.AppliesTo(context).Should().BeTrue(); + } + + [Fact] + public void AppliesTo_WhenConditionDoesNotMatchExpectedFalse_ShouldReturnFalse() + { + var inner = new TestPolicy(); + + var sut = new ConditionalAccessPolicy( + _ => true, + expected: false, + inner); + + var context = + TestAccessContext.WithAction("users.get.self"); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Fact] + public void AppliesTo_ShouldPassContextToCondition() + { + AccessContext? receivedContext = null; + + var inner = new TestPolicy(); + + var sut = new ConditionalAccessPolicy( + context => + { + receivedContext = context; + return true; + }, + expected: true, + inner); + + var context = + TestAccessContext.WithAction("users.get.self"); + + sut.AppliesTo(context); + + receivedContext.Should().BeSameAs(context); + } + + [Fact] + public void Decide_ShouldDelegateToInnerPolicy() + { + var inner = new TestPolicy( + AccessDecision.Deny("inner_denied")); + + var sut = new ConditionalAccessPolicy( + _ => true, + expected: true, + inner); + + var context = + TestAccessContext.WithAction("users.get.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("inner_denied"); + + inner.DecideCallCount.Should().Be(1); + inner.LastContext.Should().BeSameAs(context); + } + + [Fact] + public void Decide_ShouldReturnInnerAllowDecision() + { + var inner = new TestPolicy( + AccessDecision.Allow()); + + var sut = new ConditionalAccessPolicy( + _ => true, + expected: true, + inner); + + var context = + TestAccessContext.WithAction("users.get.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeTrue(); + inner.DecideCallCount.Should().Be(1); + } + + private sealed class TestPolicy : IAccessPolicy + { + private readonly AccessDecision _decision; + + public int DecideCallCount { get; private set; } + + public AccessContext? LastContext { get; private set; } + + public TestPolicy() + : this(AccessDecision.Allow()) + { + } + + public TestPolicy(AccessDecision decision) + { + _decision = decision; + } + + public bool AppliesTo(AccessContext context) + { + return true; + } + + public AccessDecision Decide(AccessContext context) + { + DecideCallCount++; + LastContext = context; + + return _decision; + } + } + } + + public sealed class MustHavePermissionPolicyTests + { + [Fact] + public void AppliesTo_AdminAction_ShouldReturnTrue() + { + var sut = new MustHavePermissionPolicy(); + + var context = + TestAccessContext.WithAction("users.update.admin"); + + sut.AppliesTo(context).Should().BeTrue(); + } + + [Fact] + public void AppliesTo_AdminActionWithDifferentCasing_ShouldReturnTrue() + { + var sut = new MustHavePermissionPolicy(); + + var context = + TestAccessContext.WithAction("users.update.ADMIN"); + + sut.AppliesTo(context).Should().BeTrue(); + } + + [Theory] + [InlineData("users.update.self")] + [InlineData("users.update.system")] + [InlineData("users.update.anonymous")] + [InlineData("users.update")] + public void AppliesTo_NonAdminAction_ShouldReturnFalse(string action) + { + var sut = new MustHavePermissionPolicy(); + + var context = + TestAccessContext.WithAction(action); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Fact] + public void Decide_WhenPermissionsAttributeIsMissing_ShouldDeny() + { + var sut = new MustHavePermissionPolicy(); + + var context = + TestAccessContext.WithAction("users.update.admin"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("missing_permission"); + } + + [Fact] + public void Decide_WhenPermissionsAttributeHasWrongType_ShouldDeny() + { + var sut = new MustHavePermissionPolicy(); + + var context = + TestAccessContext + .WithAction("users.update.admin") + .WithAttribute( + UAuthConstants.Access.Permissions, + "invalid-permissions"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("missing_permission"); + } + } + + [Fact] + public void Decide_WhenPermissionAllowsAction_ShouldAllow() + { + var sut = new MustHavePermissionPolicy(); + + var permissions = + CreatePermissions("users.update.admin"); + + var context = + TestAccessContext + .WithAction("users.update.admin") + .WithAttribute( + UAuthConstants.Access.Permissions, + permissions); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeTrue(); + } + + [Fact] + public void Decide_WhenPermissionDoesNotAllowAction_ShouldDeny() + { + var sut = new MustHavePermissionPolicy(); + + var permissions = + CreatePermissions("sessions.revoke.admin"); + + var context = + TestAccessContext + .WithAction("users.update.admin") + .WithAttribute( + UAuthConstants.Access.Permissions, + permissions); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("missing_permission"); + } + + [Fact] + public void Decide_WhenPermissionAllowsExactAction_ShouldAllow() + { + var sut = new MustHavePermissionPolicy(); + + var permissions = + CreatePermissions("users.update.admin"); + + var context = + TestAccessContext + .WithAction("users.update.admin") + .WithAttribute( + UAuthConstants.Access.Permissions, + permissions); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeTrue(); + result.DenyReason.Should().BeNull(); + } + + public sealed class RequireActiveUserPolicyTests + { + [Fact] + public void AppliesTo_AuthenticatedUser_ShouldReturnTrue() + { + var sut = CreatePolicy(); + + var context = TestAccessContext.ForUser( + UserKey.New(), + "users.get.self"); + + sut.AppliesTo(context).Should().BeTrue(); + } + + [Fact] + public void AppliesTo_UnauthenticatedUser_ShouldReturnFalse() + { + var sut = CreatePolicy(); + + var context = + TestAccessContext.WithAction("users.get.self"); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Fact] + public void AppliesTo_AnonymousAction_ShouldReturnFalse() + { + var sut = CreatePolicy(); + + var context = TestAccessContext.ForUser( + UserKey.New(), + "users.create.anonymous"); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Fact] + public void AppliesTo_AllowedInactiveAction_ShouldReturnFalse() + { + var sut = CreatePolicy(); + + var context = TestAccessContext.ForUser( + UserKey.New(), + UAuthActions.Users.ChangeStatusSelf); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Fact] + public void AppliesTo_SystemActor_ShouldReturnFalse() + { + var sut = CreatePolicy(); + + var context = new AccessContext( + actorUserKey: null, + actorTenant: TenantKey.System, + isAuthenticated: true, + isSystemActor: true, + actorChainId: null, + resource: "users", + targetUserKey: null, + resourceTenant: TenantKey.System, + action: "users.get.admin", + attributes: EmptyAttributes.Instance); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Fact] + public void Decide_WhenActorIsMissing_ShouldDeny() + { + var runtime = new TestUserRuntimeStateProvider(); + var sut = new RequireActiveUserPolicy(runtime); + + var context = + TestAccessContext.WithAction("users.get.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("missing_actor"); + + runtime.CallCount.Should().Be(0); + } + + [Fact] + public void Decide_WhenRuntimeStateIsMissing_ShouldDeny() + { + var runtime = new TestUserRuntimeStateProvider + { + Result = null + }; + + var sut = new RequireActiveUserPolicy(runtime); + + var context = TestAccessContext.ForUser( + UserKey.New(), + "users.get.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("user_not_found"); + } + + [Fact] + public void Decide_WhenUserDoesNotExist_ShouldDeny() + { + var runtime = new TestUserRuntimeStateProvider + { + Result = CreateState( + exists: false, + isDeleted: false, + isActive: false) + }; + + var sut = new RequireActiveUserPolicy(runtime); + + var context = TestAccessContext.ForUser( + UserKey.New(), + "users.get.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("user_not_found"); + } + + [Fact] + public void Decide_WhenUserIsDeleted_ShouldDeny() + { + var runtime = new TestUserRuntimeStateProvider + { + Result = CreateState( + exists: true, + isDeleted: true, + isActive: false) + }; + + var sut = new RequireActiveUserPolicy(runtime); + + var context = TestAccessContext.ForUser( + UserKey.New(), + "users.get.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("user_not_found"); + } + + [Fact] + public void Decide_WhenUserIsInactive_ShouldDeny() + { + var runtime = new TestUserRuntimeStateProvider + { + Result = CreateState( + exists: true, + isDeleted: false, + isActive: false) + }; + + var sut = new RequireActiveUserPolicy(runtime); + + var context = TestAccessContext.ForUser( + UserKey.New(), + "users.get.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("user_not_active"); + } + + [Fact] + public void Decide_WhenUserIsActive_ShouldAllow() + { + var runtime = new TestUserRuntimeStateProvider + { + Result = CreateState( + exists: true, + isDeleted: false, + isActive: true) + }; + + var sut = new RequireActiveUserPolicy(runtime); + + var context = TestAccessContext.ForUser( + UserKey.New(), + "users.get.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeTrue(); + result.DenyReason.Should().BeNull(); + } + + [Fact] + public void Decide_ShouldQueryRuntimeUsingActorTenantAndUserKey() + { + var userKey = UserKey.New(); + var tenant = TenantKey.FromExternal("tenant-a"); + + var runtime = new TestUserRuntimeStateProvider + { + Result = CreateState( + exists: true, + isDeleted: false, + isActive: true, + userKey: userKey) + }; + + var sut = new RequireActiveUserPolicy(runtime); + var context = TestAccessContext.ForUser(userKey, "users.get.self", tenant); + + sut.Decide(context); + + runtime.LastTenant.Should().Be(tenant); + runtime.LastUserKey.Should().Be(userKey); + runtime.CallCount.Should().Be(1); + } + + private static RequireActiveUserPolicy CreatePolicy() + { + return new RequireActiveUserPolicy( + new TestUserRuntimeStateProvider()); + } + + private static UserRuntimeRecord CreateState(bool exists, bool isDeleted, bool isActive, UserKey? userKey = null) + { + return new UserRuntimeRecord + { + UserKey = userKey ?? UserKey.New(), + Exists = exists, + IsDeleted = isDeleted, + IsActive = isActive, + CanAuthenticate = isActive + }; + } + + private sealed class TestUserRuntimeStateProvider : IUserRuntimeStateProvider + { + public UserRuntimeRecord? Result { get; init; } + + public int CallCount { get; private set; } + + public TenantKey? LastTenant { get; private set; } + + public UserKey? LastUserKey { get; private set; } + + public Task GetAsync( + TenantKey tenant, + UserKey userKey, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + CallCount++; + + LastTenant = tenant; + LastUserKey = userKey; + + return Task.FromResult(Result); + } + } + } + + private static CompiledPermissionSet CreatePermissions( + params string[] permissions) + { + return new CompiledPermissionSet( + permissions.Select(Permission.From)); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/PolicyBuilderTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/PolicyBuilderTests.cs new file mode 100644 index 00000000..613a90f1 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/PolicyBuilderTests.cs @@ -0,0 +1,390 @@ +using CodeBeam.UltimateAuth.Authorization.Policies; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Policies; +using CodeBeam.UltimateAuth.Policies.Registry; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class PolicyBuilderTests +{ + [Fact] + public void For_ShouldRegisterPolicyForSpecifiedPrefix() + { + var (builder, registry, services) = CreateBuilder(); + + builder + .For("users.") + .RequireAuthenticated(); + + var compiled = registry.Build(); + + var policies = compiled.Resolve( + TestAccessContext.WithAction("users.get.self"), + services); + + policies.Should().ContainSingle() + .Which.Should().BeOfType(); + } + + [Fact] + public void For_ShouldNotApplyPolicyToDifferentPrefix() + { + var (builder, registry, services) = CreateBuilder(); + + builder + .For("users.") + .RequireAuthenticated(); + + var compiled = registry.Build(); + + var policies = compiled.Resolve( + TestAccessContext.WithAction("sessions.get.self"), + services); + + policies.Should().BeEmpty(); + } + + [Fact] + public void Global_ShouldRegisterPolicyForEveryAction() + { + var (builder, registry, services) = CreateBuilder(); + + builder + .Global() + .DenyCrossTenant(); + + var compiled = registry.Build(); + + var users = compiled.Resolve( + TestAccessContext.WithAction("users.get.self"), + services); + + var sessions = compiled.Resolve( + TestAccessContext.WithAction("sessions.revoke.self"), + services); + + users.Should().ContainSingle() + .Which.Should().BeOfType(); + + sessions.Should().ContainSingle() + .Which.Should().BeOfType(); + } + + [Fact] + public void ScopeBuilder_ShouldSupportFluentPolicyRegistration() + { + var (builder, registry, services) = CreateBuilder(); + + builder + .For("users.") + .RequireAuthenticated() + .RequireSelf() + .RequirePermission() + .DenyCrossTenant(); + + var compiled = registry.Build(); + + var selfPolicies = compiled.Resolve( + TestAccessContext.WithAction("users.update.self"), + services); + + selfPolicies.Should().Contain(x => + x is RequireAuthenticatedPolicy); + + selfPolicies.Should().Contain(x => + x is RequireSelfPolicy); + + selfPolicies.Should().Contain(x => + x is DenyCrossTenantPolicy); + + selfPolicies.Should().NotContain(x => + x is MustHavePermissionPolicy); + + var adminPolicies = compiled.Resolve( + TestAccessContext.WithAction("users.update.admin"), + services); + + adminPolicies.Should().Contain(x => + x is RequireAuthenticatedPolicy); + + adminPolicies.Should().Contain(x => + x is MustHavePermissionPolicy); + + adminPolicies.Should().Contain(x => + x is DenyCrossTenantPolicy); + + adminPolicies.Should().NotContain(x => + x is RequireSelfPolicy); + } + + [Fact] + public void RequireAuthenticated_ShouldRegisterCorrectPolicy() + { + AssertRegisteredPolicy( + scope => scope.RequireAuthenticated()); + } + + [Fact] + public void RequireSelf_ShouldRegisterCorrectPolicy() + { + AssertRegisteredPolicy(scope => scope.RequireSelf(), "users.test.self"); + } + + [Fact] + public void RequirePermission_ShouldRegisterCorrectPolicy() + { + AssertRegisteredPolicy(scope => scope.RequirePermission(), "users.test.admin"); + } + + [Fact] + public void DenyCrossTenant_ShouldRegisterCorrectPolicy() + { + AssertRegisteredPolicy( + scope => scope.DenyCrossTenant()); + } + + private static void AssertRegisteredPolicy( + Action configure) + where TPolicy : IAccessPolicy + { + var (builder, registry, services) = CreateBuilder(); + + configure(builder.For("users.")); + + var compiled = registry.Build(); + + var policies = compiled.Resolve( + TestAccessContext.WithAction("users.test"), + services); + + policies.Should().ContainSingle() + .Which.Should().BeOfType(); + } + + [Fact] + public void Then_WhenConditionIsTrue_ShouldIncludePolicy() + { + var (scope, registry, services) = CreateScope(); + + scope + .When(_ => true) + .Then() + .RequireAuthenticated(); + + var compiled = registry.Build(); + + var policies = compiled.Resolve( + TestAccessContext.WithAction("users.get.self"), + services); + + policies.Should().ContainSingle(); + + policies.Single() + .Should() + .BeOfType(); + } + + [Fact] + public void Then_WhenConditionIsFalse_ShouldExcludePolicy() + { + var (scope, registry, services) = CreateScope(); + + scope + .When(_ => false) + .Then() + .RequireAuthenticated(); + + var compiled = registry.Build(); + + var policies = compiled.Resolve( + TestAccessContext.WithAction("users.get.self"), + services); + + policies.Should().BeEmpty(); + } + + [Fact] + public void Otherwise_WhenConditionIsFalse_ShouldIncludePolicy() + { + var (scope, registry, services) = CreateScope(); + + scope + .When(_ => false) + .Otherwise() + .RequireAuthenticated(); + + var compiled = registry.Build(); + + var policies = compiled.Resolve( + TestAccessContext.WithAction("users.get.self"), + services); + + policies.Should().ContainSingle(); + + policies.Single() + .Should() + .BeOfType(); + } + + [Fact] + public void Otherwise_WhenConditionIsTrue_ShouldExcludePolicy() + { + var (scope, registry, services) = CreateScope(); + + scope + .When(_ => true) + .Otherwise() + .RequireAuthenticated(); + + var compiled = registry.Build(); + + var policies = compiled.Resolve( + TestAccessContext.WithAction("users.get.self"), + services); + + policies.Should().BeEmpty(); + } + + [Fact] + public void ConditionalPolicy_ShouldReceiveRuntimeAccessContext() + { + var (scope, registry, services) = CreateScope(); + + scope + .When(context => + context.Action == "users.update.self") + .Then() + .RequireAuthenticated(); + + var compiled = registry.Build(); + + var matching = compiled.Resolve( + TestAccessContext.WithAction("users.update.self"), + services); + + var nonMatching = compiled.Resolve( + TestAccessContext.WithAction("users.get.self"), + services); + + matching.Should().ContainSingle(); + nonMatching.Should().BeEmpty(); + } + + [Fact] + public void Then_ShouldSupportMultiplePolicies() + { + var (scope, registry, services) = CreateScope(); + + scope + .When(_ => true) + .Then() + .RequireAuthenticated() + .RequireSelf() + .RequirePermission() + .DenyCrossTenant(); + + var compiled = registry.Build(); + + var policies = compiled.Resolve( + TestAccessContext.WithAction("users.update.self"), + services); + + policies.Should().HaveCount(4); + + policies.Should() + .OnlyContain(x => x is ConditionalAccessPolicy); + } + + [Fact] + public void Then_ShouldPreserveActionPrefix() + { + var services = new ServiceCollection() + .BuildServiceProvider(); + + var registry = new AccessPolicyRegistry(); + + var scope = + new PolicyScopeBuilder( + "users.", + registry, + services); + + scope + .When(_ => true) + .Then() + .RequireAuthenticated(); + + var compiled = registry.Build(); + + var matching = compiled.Resolve( + TestAccessContext.WithAction("users.get.self"), + services); + + var differentPrefix = compiled.Resolve( + TestAccessContext.WithAction("sessions.get.self"), + services); + + matching.Should().ContainSingle(); + differentPrefix.Should().BeEmpty(); + } + + [Fact] + public void For_WhenThen_ShouldBeAvailableThroughPublicBuilderContract() + { + var services = new ServiceCollection() + .BuildServiceProvider(); + + var registry = new AccessPolicyRegistry(); + + IPolicyBuilder builder = + new PolicyBuilder(registry, services); + + builder + .For("users.") + .When(_ => true) + .Then() + .RequireAuthenticated(); + + var compiled = registry.Build(); + + var policies = compiled.Resolve( + TestAccessContext.WithAction("users.get.self"), + services); + + policies.Should().ContainSingle(); + } + + private static void AssertRegisteredPolicy(Action configure, string action) where TPolicy : IAccessPolicy + { + var (builder, registry, services) = CreateBuilder(); + + configure(builder.For("users.")); + + var compiled = registry.Build(); + var policies = compiled.Resolve(TestAccessContext.WithAction(action), services); + + policies.Should().ContainSingle().Which.Should().BeOfType(); + } + + private static (PolicyScopeBuilder Scope, AccessPolicyRegistry Registry, ServiceProvider Services) CreateScope() + { + var services = new ServiceCollection().BuildServiceProvider(); + var registry = new AccessPolicyRegistry(); + + return ( + new PolicyScopeBuilder("users.", registry, services), + registry, + services); + } + + private static (PolicyBuilder Builder, AccessPolicyRegistry Registry, ServiceProvider Services) CreateBuilder() + { + var services = new ServiceCollection().BuildServiceProvider(); + var registry = new AccessPolicyRegistry(); + + return (new PolicyBuilder(registry, services), registry, services); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/PolicyTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/PolicyTests.cs new file mode 100644 index 00000000..8f25e3f0 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/PolicyTests.cs @@ -0,0 +1,331 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Policies.Registry; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Policies; + +public sealed class PolicyTests +{ + [Fact] + public void Constructor_ShouldStoreActionPrefixAndFactory() + { + Func factory = _ => new TestPolicy(); + + var rule = new PolicyRule("users.create", factory); + + rule.ActionPrefix.Should().Be("users.create"); + rule.Factory.Should().BeSameAs(factory); + } + + [Theory] + [InlineData("users.create")] + [InlineData("users.create.admin")] + [InlineData("USERS.CREATE.ADMIN")] + public void Matches_WhenActionStartsWithPrefix_ShouldReturnTrue(string action) + { + var rule = new PolicyRule("users.create", _ => new TestPolicy()); + + rule.Matches(action).Should().BeTrue(); + } + + [Theory] + [InlineData("users.update")] + [InlineData("sessions.create")] + [InlineData("user.create")] + public void Matches_WhenActionDoesNotStartWithPrefix_ShouldReturnFalse(string action) + { + var rule = new PolicyRule("users.create", _ => new TestPolicy()); + + rule.Matches(action).Should().BeFalse(); + } + + [Fact] + public void Build_WhenCalledTwice_ShouldThrow() + { + var registry = new AccessPolicyRegistry(); + + registry.Build(); + + var act = () => registry.Build(); + + act.Should() + .Throw() + .WithMessage( + "AccessPolicyRegistry.Build() can only be called once."); + } + + [Fact] + public void Add_AfterBuild_ShouldThrow() + { + var registry = new AccessPolicyRegistry(); + + registry.Build(); + + var act = () => + registry.Add( + "users.", + _ => new TestPolicy()); + + act.Should() + .Throw() + .WithMessage( + "AccessPolicyRegistry is already built. Policies cannot be modified after Build()."); + } + + [Fact] + public void Resolve_ShouldReturnPoliciesMatchingActionPrefix() + { + var registry = new AccessPolicyRegistry(); + + registry.Add( + "users.", + _ => new TestPolicy("users")); + + registry.Add( + "sessions.", + _ => new TestPolicy("sessions")); + + var context = CreateContext("users.create"); + + using var services = + new ServiceCollection().BuildServiceProvider(); + + var policies = + registry.Resolve(context, services); + + policies.Should().ContainSingle(); + + policies + .Cast() + .Single() + .Name.Should() + .Be("users"); + } + + [Fact] + public void Resolve_ShouldMatchPrefixCaseInsensitively() + { + var registry = new AccessPolicyRegistry(); + + registry.Add("USERS.", _ => new TestPolicy("users")); + + var context = CreateContext("users.create"); + + using var services = new ServiceCollection().BuildServiceProvider(); + + var policies = registry.Resolve(context, services); + + policies.Should().ContainSingle(); + } + + [Fact] + public void Resolve_WhenNoPrefixMatches_ShouldReturnEmpty() + { + var registry = new AccessPolicyRegistry(); + + registry.Add("sessions.", _ => new TestPolicy("sessions")); + + var context = CreateContext("users.create"); + + using var services = + new ServiceCollection().BuildServiceProvider(); + + var policies = + registry.Resolve(context, services); + + policies.Should().BeEmpty(); + } + + [Fact] + public void CompiledSet_ShouldIncludePolicy_WhenPrefixMatchesAndPolicyApplies() + { + var registry = new AccessPolicyRegistry(); + + registry.Add( + "users.", + _ => new TestPolicy( + name: "matching", + applies: true)); + + var compiled = registry.Build(); + + var context = CreateContext("users.create"); + + using var services = + new ServiceCollection().BuildServiceProvider(); + + var policies = + compiled.Resolve(context, services); + + policies.Should().ContainSingle(); + } + + [Fact] + public void CompiledSet_ShouldExcludePolicy_WhenPolicyDoesNotApply() + { + var registry = new AccessPolicyRegistry(); + + registry.Add( + "users.", + _ => new TestPolicy( + name: "not-applicable", + applies: false)); + + var compiled = registry.Build(); + + var context = CreateContext("users.create"); + + using var services = + new ServiceCollection().BuildServiceProvider(); + + var policies = + compiled.Resolve(context, services); + + policies.Should().BeEmpty(); + } + + [Fact] + public void CompiledSet_ShouldNotCreatePolicy_WhenPrefixDoesNotMatch() + { + var registry = new AccessPolicyRegistry(); + + var factoryCalled = false; + + registry.Add( + "sessions.", + _ => + { + factoryCalled = true; + return new TestPolicy("sessions", true); + }); + + var compiled = registry.Build(); + + var context = CreateContext("users.create"); + + using var services = + new ServiceCollection().BuildServiceProvider(); + + var policies = + compiled.Resolve(context, services); + + policies.Should().BeEmpty(); + factoryCalled.Should().BeFalse(); + } + + [Fact] + public void CompiledSet_ShouldProvideServiceProviderToPolicyFactory() + { + var dependency = new TestDependency(); + + var services = new ServiceCollection() + .AddSingleton(dependency) + .BuildServiceProvider(); + + var registry = new AccessPolicyRegistry(); + + registry.Add( + "users.", + sp => new DependencyPolicy( + sp.GetRequiredService())); + + var compiled = registry.Build(); + + var policies = + compiled.Resolve( + CreateContext("users.create"), + services); + + var policy = + policies.Should() + .ContainSingle() + .Subject + .Should() + .BeOfType() + .Subject; + + policy.Dependency.Should().BeSameAs(dependency); + } + + [Fact] + public void Build_ShouldOrderPoliciesByPrefixLength() + { + var registry = new AccessPolicyRegistry(); + + registry.Add( + "users.create.", + _ => new TestPolicy("specific")); + + registry.Add( + "", + _ => new TestPolicy("global")); + + registry.Add( + "users.", + _ => new TestPolicy("users")); + + var compiled = registry.Build(); + + using var services = + new ServiceCollection().BuildServiceProvider(); + + var policies = + compiled.Resolve( + CreateContext("users.create.admin"), + services); + + policies + .Cast() + .Select(x => x.Name) + .Should() + .ContainInOrder( + "global", + "users", + "specific"); + } + + private sealed class TestPolicy : IAccessPolicy + { + private readonly bool _applies; + + public string? Name { get; } + + public TestPolicy(string? name = null, bool applies = true) + { + Name = name; + _applies = applies; + } + + public bool AppliesTo(AccessContext context) => _applies; + + public AccessDecision Decide(AccessContext context) => AccessDecision.Allow(); + } + + private sealed class TestDependency + { + } + + private sealed class DependencyPolicy : IAccessPolicy + { + public TestDependency Dependency { get; } + + public DependencyPolicy(TestDependency dependency) + { + Dependency = dependency; + } + + public bool AppliesTo(AccessContext context) + => true; + + public AccessDecision Decide(AccessContext context) + => AccessDecision.Allow(); + } + + private static AccessContext CreateContext(string action) + { + return TestAccessContext.WithAction(action); + } +} From cf40f1b84338d5221719807d88fd1f0d69d4de0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 5 Oct 2026 14:16:04 +0300 Subject: [PATCH 06/16] Component Test --- .../UAuthHubSampleSmokeTests.cs | 29 +++++++++++++++++++ .../CodeBeam.UltimateAuth.Tests.Unit.csproj | 1 + .../Samples/ComponentTestBase.cs | 27 +++++++++++++++++ .../Samples/HomeTests.cs | 23 +++++++++++++++ 4 files changed, 80 insertions(+) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/UAuthHubSampleSmokeTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Samples/ComponentTestBase.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Samples/HomeTests.cs diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UAuthHubSampleSmokeTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UAuthHubSampleSmokeTests.cs new file mode 100644 index 00000000..f224fa9a --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UAuthHubSampleSmokeTests.cs @@ -0,0 +1,29 @@ +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; + +public sealed class UAuthHubSampleSmokeTests : IClassFixture +{ + private readonly HttpClient _client; + + public UAuthHubSampleSmokeTests(AuthServerFactory factory) + { + _client = factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + [Theory] + [InlineData("/")] + [InlineData("/login")] + public async Task CriticalPages_ShouldRenderWithoutServerError(string path) + { + var response = await _client.GetAsync(path); + + ((int)response.StatusCode) + .Should() + .BeLessThan(500); + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj index f26c8560..9a20db38 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj @@ -26,6 +26,7 @@ + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Samples/ComponentTestBase.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Samples/ComponentTestBase.cs new file mode 100644 index 00000000..37f1fcf7 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Samples/ComponentTestBase.cs @@ -0,0 +1,27 @@ +using Bunit; +using CodeBeam.UltimateAuth.Client.Blazor.Extensions; +using CodeBeam.UltimateAuth.InMemory; +using CodeBeam.UltimateAuth.Server.Extensions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using MudBlazor.Services; +using MudExtensions.Services; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Samples; + +public abstract class UAuthHubComponentTestBase : BunitContext +{ + protected UAuthHubComponentTestBase() + { + JSInterop.Mode = JSRuntimeMode.Loose; + + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + + Services.AddSingleton(configuration); + + Services.AddMudServices(); + Services.AddMudExtensions(); + Services.AddUltimateAuthServer().AddUltimateAuthInMemory().AddUAuthHub(); + Services.AddUltimateAuthClientBlazor(); + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Samples/HomeTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Samples/HomeTests.cs new file mode 100644 index 00000000..a5b343ce --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Samples/HomeTests.cs @@ -0,0 +1,23 @@ +using Bunit; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Layout; +using CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Pages; +using FluentAssertions; +using Microsoft.AspNetCore.Components; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Samples.UAuthHub; + +public sealed class HomeTests : UAuthHubComponentTestBase +{ + [Fact] + public void Home_ShouldRender() + { + var state = UAuthState.Anonymous(); + + var act = () => Render>(parameters => parameters + .Add(p => p.Value, state) + .AddChildContent()); + + act.Should().NotThrow(); + } +} From cc653c4f9a9c3752ac55f302f58f25c4452ee2e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 5 Oct 2026 14:29:40 +0300 Subject: [PATCH 07/16] Fix Some Tests --- .../Client/UAuthLoginRedirectTests.cs | 60 ++++++++++++------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginRedirectTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginRedirectTests.cs index 1288b2b2..5a3c5858 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginRedirectTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginRedirectTests.cs @@ -19,7 +19,12 @@ public void Render_WithoutReturnUrl_NavigatesToLoginPage() Render(); - Nav.Uri.Should().Be("http://localhost/login"); + var comp = Render(); + + comp.WaitForAssertion(() => + { + Nav.Uri.Should().Be("http://localhost/login"); + }); } [Fact] @@ -27,10 +32,13 @@ public void Render_WithRelativeReturnUrl_PreservesReturnUrl() { NavigateToRedirect("/home"); - Render(); + var comp = Render(); - Nav.Uri.Should().Be( - "http://localhost/login?uauth_return_url=%2Fhome"); + comp.WaitForAssertion(() => + { + Nav.Uri.Should().Be( + "http://localhost/login?uauth_return_url=%2Fhome"); + }); } [Fact] @@ -38,20 +46,23 @@ public void Render_WithNestedRelativeReturnUrl_PreservesAndEncodesReturnUrl() { NavigateToRedirect("/account/security?tab=sessions"); - Render(); + var comp = Render(); - var uri = Nav.ToAbsoluteUri(Nav.Uri); + comp.WaitForAssertion(() => + { + var uri = Nav.ToAbsoluteUri(Nav.Uri); - uri.AbsolutePath.Should().Be("/login"); + uri.AbsolutePath.Should().Be("/login"); - var query = - Microsoft.AspNetCore.WebUtilities.QueryHelpers - .ParseQuery(uri.Query); + var query = + Microsoft.AspNetCore.WebUtilities.QueryHelpers + .ParseQuery(uri.Query); - query[UAuthConstants.Query.ReturnUrl] - .ToString() - .Should() - .Be("/account/security?tab=sessions"); + query[UAuthConstants.Query.ReturnUrl] + .ToString() + .Should() + .Be("/account/security?tab=sessions"); + }); } [Fact] @@ -113,20 +124,23 @@ public void Render_WithAbsoluteHttpReturnUrl_PreservesReturnUrl() [InlineData("ftp://example.com/file")] [InlineData("mailto:test@example.com")] public void Render_WithUnsupportedAbsoluteScheme_DropsReturnUrl( - string returnUrl) + string returnUrl) { NavigateToRedirect(returnUrl); - Render(); + var comp = Render(); - Nav.ToAbsoluteUri(Nav.Uri) - .AbsolutePath - .Should() - .Be("/login"); + comp.WaitForAssertion(() => + { + Nav.ToAbsoluteUri(Nav.Uri) + .AbsolutePath + .Should() + .Be("/login"); - GetReturnUrlFromCurrentUri() - .Should() - .BeNull(); + GetReturnUrlFromCurrentUri() + .Should() + .BeNull(); + }); } [Fact] From d135f79133625b1e98121ce8edc76131f629821a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 5 Oct 2026 15:02:03 +0300 Subject: [PATCH 08/16] Fix UAuthLoginPageDiscovery --- .../Infrastructure/UAuthLoginPageDiscovery.cs | 191 +++++++++++++-- .../Infrastructure/UAuthLoginPageAttribute.cs | 6 + .../Client/UAuthLoginPageDiscoveryTests.cs | 218 ++++++++++++++++++ .../Samples/HomeTests.cs | 1 + 4 files changed, 393 insertions(+), 23 deletions(-) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginPageDiscoveryTests.cs diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs index a4b93b3e..212e9f62 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs @@ -3,44 +3,189 @@ namespace CodeBeam.UltimateAuth.Client.Infrastructure; /// -/// Discovers the login page route by scanning for a component decorated with the [UAuthLoginPage] attribute. If no such component is found, it defaults to "/login". If multiple components are found, an exception is thrown. -/// The resolved route is cached for subsequent calls. +/// Discovers the login page route from the component decorated with +/// . /// public static class UAuthLoginPageDiscovery { + private const string DefaultLoginRoute = "/login"; + private static string? _cached; /// - /// Resolves the login page route by scanning for a component decorated with the [UAuthLoginPage] attribute. If no such component is found, it defaults to "/login". - /// If multiple components are found, an exception is thrown. + /// Resolves the login page route by scanning loaded assemblies for a component + /// decorated with . /// - /// - /// + /// + /// Route selection order: + /// + /// Preferred route explicitly configured on . + /// Root route (/). + /// Conventional login route (/login). + /// First route in deterministic ordinal-ignore-case order. + /// Default route (/login) when the component has no route. + /// + /// public static string Resolve() { - if (_cached != null) + if (_cached is not null) return _cached; - var assemblies = AppDomain.CurrentDomain.GetAssemblies(); + var candidates = AppDomain.CurrentDomain + .GetAssemblies() + .SelectMany(GetLoadableTypes) + .Where(HasLoginPageAttribute) + .ToArray(); + + if (candidates.Length == 0) + return _cached = DefaultLoginRoute; + + if (candidates.Length > 1) + { + throw new InvalidOperationException( + "Multiple [UAuthLoginPage] components were found. " + + "Make sure only one component is marked as the UltimateAuth login page."); + } + + return _cached = ResolveRoute(candidates[0]); + } + + internal static string ResolveRoute(Type componentType) + { + ArgumentNullException.ThrowIfNull(componentType); + + var loginPage = componentType + .GetCustomAttributes(typeof(UAuthLoginPageAttribute), inherit: true) + .Cast() + .SingleOrDefault(); + + if (loginPage is null) + { + throw new InvalidOperationException( + $"Component '{componentType.FullName}' is not decorated with [UAuthLoginPage]."); + } + + var routes = componentType + .GetCustomAttributes(typeof(RouteAttribute), inherit: true) + .Cast() + .Select(x => x.Template) + .ToArray(); + + return ResolveRoute( + loginPage, + routes, + componentType.FullName); + } + + internal static string ResolveRoute( + UAuthLoginPageAttribute loginPage, + IEnumerable routes, + string? componentName = null) + { + ArgumentNullException.ThrowIfNull(loginPage); + ArgumentNullException.ThrowIfNull(routes); + + var normalizedRoutes = routes + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(NormalizeRoute) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (!string.IsNullOrWhiteSpace(loginPage.PreferredRoute)) + { + var normalizedPreferred = + NormalizeRoute(loginPage.PreferredRoute); + + var preferred = normalizedRoutes.FirstOrDefault(x => + string.Equals( + x, + normalizedPreferred, + StringComparison.OrdinalIgnoreCase)); - var candidates = assemblies - .SelectMany(a => + if (preferred is null) { - try { return a.GetTypes(); } - catch { return Array.Empty(); } - }) - .Where(t => t.GetCustomAttributes(typeof(UAuthLoginPageAttribute), true).Any()) - .ToList(); + var componentDescription = + string.IsNullOrWhiteSpace(componentName) + ? "the login page component" + : $"component '{componentName}'"; + + throw new InvalidOperationException( + $"Preferred login route '{loginPage.PreferredRoute}' " + + $"is not defined on {componentDescription}."); + } + + return preferred; + } + + var root = normalizedRoutes.FirstOrDefault(x => + string.Equals( + x, + "/", + StringComparison.OrdinalIgnoreCase)); + + if (root is not null) + return root; + + var login = normalizedRoutes.FirstOrDefault(x => + string.Equals( + x, + DefaultLoginRoute, + StringComparison.OrdinalIgnoreCase)); + + if (login is not null) + return login; + + if (normalizedRoutes.Length > 0) + { + return normalizedRoutes + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .First(); + } + + return DefaultLoginRoute; + } + + private static IEnumerable GetLoadableTypes( + System.Reflection.Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (System.Reflection.ReflectionTypeLoadException ex) + { + return ex.Types + .Where(x => x is not null) + .Cast(); + } + catch + { + return Array.Empty(); + } + } + + private static bool HasLoginPageAttribute(Type type) + { + return type + .GetCustomAttributes( + typeof(UAuthLoginPageAttribute), + inherit: true) + .Any(); + } + + private static string NormalizeRoute(string route) + { + if (string.IsNullOrWhiteSpace(route)) + return "/"; - if (candidates.Count == 0) - return _cached = "/login"; + route = route.Trim(); - if (candidates.Count > 1) - throw new InvalidOperationException("Multiple [UAuthLoginPage] found. Make sure you only have one login page that attribute defined or define Navigation.LoginResolver explicitly."); + if (!route.StartsWith('/')) + route = "/" + route; - var routeAttr = candidates[0].GetCustomAttributes(typeof(RouteAttribute), true).FirstOrDefault() as RouteAttribute; + if (route.Length > 1) + route = route.TrimEnd('/'); - _cached = routeAttr?.Template ?? "/login"; - return _cached; + return route; } -} +} \ No newline at end of file diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs index ab7db38c..d1e419d7 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs @@ -6,4 +6,10 @@ [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] public sealed class UAuthLoginPageAttribute : Attribute { + public string? PreferredRoute { get; } + + public UAuthLoginPageAttribute(string? preferredRoute = null) + { + PreferredRoute = preferredRoute; + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginPageDiscoveryTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginPageDiscoveryTests.cs new file mode 100644 index 00000000..25cbda61 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginPageDiscoveryTests.cs @@ -0,0 +1,218 @@ +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Client.Infrastructure; + +public sealed class UAuthLoginPageDiscoveryTests +{ + [Fact] + public void ResolveRoute_WithNoRoutes_ShouldReturnDefaultLoginRoute() + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute(), + Array.Empty()); + + result.Should().Be("/login"); + } + + [Fact] + public void ResolveRoute_WithSingleRoute_ShouldReturnRoute() + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute(), + new[] + { + "/sign-in" + }); + + result.Should().Be("/sign-in"); + } + + [Fact] + public void ResolveRoute_WithRootRoute_ShouldPreferRoot() + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute(), + new[] + { + "/other", + "/", + "/login" + }); + + result.Should().Be("/"); + } + + [Fact] + public void ResolveRoute_WithoutRoot_ShouldPreferLoginRoute() + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute(), + new[] + { + "/account", + "/login", + "/signin" + }); + + result.Should().Be("/login"); + } + + [Fact] + public void ResolveRoute_WithPreferredRoute_ShouldPreferExplicitRoute() + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute("/sign-in"), + new[] + { + "/", + "/login", + "/sign-in" + }); + + result.Should().Be("/sign-in"); + } + + [Fact] + public void ResolveRoute_WithPreferredRouteWithoutLeadingSlash_ShouldResolveRoute() + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute("sign-in"), + new[] + { + "/", + "/sign-in" + }); + + result.Should().Be("/sign-in"); + } + + [Fact] + public void ResolveRoute_WithPreferredRoute_ShouldMatchCaseInsensitively() + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute("/LOGIN"), + new[] + { + "/", + "/login" + }); + + result.Should().Be("/login"); + } + + [Fact] + public void ResolveRoute_WithPreferredRouteTrailingSlash_ShouldNormalizeRoute() + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute("/login/"), + new[] + { + "/login" + }); + + result.Should().Be("/login"); + } + + [Fact] + public void ResolveRoute_WithUndefinedPreferredRoute_ShouldThrow() + { + var act = () => + UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute("/sign-in"), + new[] + { + "/", + "/login" + }, + "TestLoginPage"); + + act.Should() + .Throw() + .WithMessage( + "*Preferred login route '/sign-in'*TestLoginPage*"); + } + + [Fact] + public void ResolveRoute_WithMultipleCustomRoutes_ShouldUseDeterministicFallback() + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute(), + new[] + { + "/z-login", + "/custom-login", + "/account" + }); + + result.Should().Be("/account"); + } + + [Fact] + public void ResolveRoute_ShouldNormalizeRoutes() + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute(), + new[] + { + "login/" + }); + + result.Should().Be("/login"); + } + + [Fact] + public void ResolveRoute_ShouldIgnoreDuplicateRoutes() + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute(), + new[] + { + "/login", + "/LOGIN", + "/login/" + }); + + result.Should().Be("/login"); + } + + [Theory] + [InlineData("/", "/login")] + [InlineData("/login", "/")] + public void ResolveRoute_WithRootAndLogin_ShouldAlwaysPreferRoot_RegardlessOfDiscoveryOrder( + string first, + string second) + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute(), + new[] + { + first, + second + }); + + result.Should().Be("/"); + } + + [Theory] + [InlineData("/z", "/a", "/m")] + [InlineData("/m", "/z", "/a")] + [InlineData("/a", "/m", "/z")] + public void ResolveRoute_CustomFallback_ShouldBeIndependentOfDiscoveryOrder( + string first, + string second, + string third) + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute(), + new[] + { + first, + second, + third + }); + + result.Should().Be("/a"); + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Samples/HomeTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Samples/HomeTests.cs index a5b343ce..2d62992c 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Samples/HomeTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Samples/HomeTests.cs @@ -1,5 +1,6 @@ using Bunit; using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Layout; using CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Pages; using FluentAssertions; From 6781fcd8288dc95aab92bd538d3f2a2a2aec5a7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 5 Oct 2026 15:26:21 +0300 Subject: [PATCH 09/16] Fix Tests --- .../Components/UAuthLoginDispatch.razor | 3 +- .../Extensions/ServiceCollectionExtensions.cs | 2 + .../Infrastructure/UAuthLoginPageDiscovery.cs | 2 +- .../Infrastructure/UAuthLoginPageResolver.cs | 11 ++++ .../Infrastructure/IUAuthLoginPageResolver.cs | 6 +++ .../Client/UAuthLoginPageDiscoveryTests.cs | 1 + .../Client/UAuthLoginRedirectTests.cs | 51 +++++++++++++++++-- 7 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageResolver.cs create mode 100644 src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthLoginPageResolver.cs diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginDispatch.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginDispatch.razor index af62ef65..183f19ce 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginDispatch.razor +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginDispatch.razor @@ -3,6 +3,7 @@ @namespace CodeBeam.UltimateAuth.Client.Blazor @using CodeBeam.UltimateAuth.Core.Defaults @using Microsoft.AspNetCore.WebUtilities +@inject IUAuthLoginPageResolver LoginPageResolver @inject NavigationManager Nav @code { @@ -20,7 +21,7 @@ ? value.ToString() : null; - var loginRoute = UAuthLoginPageDiscovery.Resolve(); + var loginRoute = LoginPageResolver.Resolve(); string target; string? safeReturnUrl = null; diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs index 5a8f3d95..17d6ea68 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs @@ -52,6 +52,8 @@ private static IServiceCollection AddUltimateAuthClientBlazorInternal(this IServ services.AddScoped(); services.AddScoped(); + services.TryAddSingleton(); + services.AddAuthorizationCore(); return services; diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs index 212e9f62..84cc3af5 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Components; -namespace CodeBeam.UltimateAuth.Client.Infrastructure; +namespace CodeBeam.UltimateAuth.Client.Blazor.Infrastructure; /// /// Discovers the login page route from the component decorated with diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageResolver.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageResolver.cs new file mode 100644 index 00000000..43a9dff8 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageResolver.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Client.Infrastructure; + +namespace CodeBeam.UltimateAuth.Client.Blazor.Infrastructure; + +internal sealed class UAuthLoginPageResolver : IUAuthLoginPageResolver +{ + public string Resolve() + { + return UAuthLoginPageDiscovery.Resolve(); + } +} \ No newline at end of file diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthLoginPageResolver.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthLoginPageResolver.cs new file mode 100644 index 00000000..72704a68 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthLoginPageResolver.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +public interface IUAuthLoginPageResolver +{ + string Resolve(); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginPageDiscoveryTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginPageDiscoveryTests.cs index 25cbda61..2a8a2855 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginPageDiscoveryTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginPageDiscoveryTests.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Blazor.Infrastructure; using CodeBeam.UltimateAuth.Client.Infrastructure; using FluentAssertions; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginRedirectTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginRedirectTests.cs index 5a3c5858..7650562c 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginRedirectTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginRedirectTests.cs @@ -1,24 +1,29 @@ using Bunit; using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Core.Defaults; using FluentAssertions; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace CodeBeam.UltimateAuth.Tests.Unit.Client.Blazor; public sealed class UAuthLoginRedirectTests : BunitContext { - private NavigationManager Nav => - Services.GetRequiredService(); + private NavigationManager Nav => Services.GetRequiredService(); + + public UAuthLoginRedirectTests() + { + Services.AddSingleton(new TestLoginPageResolver("/login")); + UseLoginRoute("/login"); + } [Fact] public void Render_WithoutReturnUrl_NavigatesToLoginPage() { Navigate(UAuthConstants.Routes.LoginRedirect); - Render(); - var comp = Render(); comp.WaitForAssertion(() => @@ -219,6 +224,29 @@ public void Render_WithUAuthReturnUrl_ConsumesIt() .Be("/home"); } + [Fact] + public void Render_ShouldUseResolvedLoginRoute() + { + UseLoginRoute("/custom-sign-in"); + + Navigate(UAuthConstants.Routes.LoginRedirect); + + Render(); + + Nav.ToAbsoluteUri(Nav.Uri) + .AbsolutePath + .Should() + .Be("/custom-sign-in"); + } + + private void UseLoginRoute(string route) + { + Services.RemoveAll(); + + Services.AddSingleton( + new TestLoginPageResolver(route)); + } + private void NavigateToRedirect(string returnUrl) { Nav.NavigateTo(UAuthConstants.Routes.LoginRedirect); @@ -247,4 +275,19 @@ private void Navigate(string relativeUri) ? value.ToString() : null; } + + private sealed class TestLoginPageResolver : IUAuthLoginPageResolver + { + private readonly string _route; + + public TestLoginPageResolver(string route) + { + _route = route; + } + + public string Resolve() + { + return _route; + } + } } \ No newline at end of file From ced9e58407875746f1fd95cb8747a7ffc9c2fea1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 5 Oct 2026 15:45:53 +0300 Subject: [PATCH 10/16] Bundle Tests --- .../AssemblyVisibility.cs | 3 + .../AssemblyVisibility.cs | 3 + .../Bundle/UAuthDbContextTests.cs | 90 ++++++++++++ .../Bundle/UAuthEfCoreOptionsTests.cs | 52 +++++++ ...eAuthEntityFrameworkCoreExtensionsTests.cs | 130 ++++++++++++++++++ 5 files changed, 278 insertions(+) create mode 100644 src/bundle/CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle/AssemblyVisibility.cs create mode 100644 src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/AssemblyVisibility.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Bundle/UAuthDbContextTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Bundle/UAuthEfCoreOptionsTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Bundle/UltimateAuthEntityFrameworkCoreExtensionsTests.cs diff --git a/src/bundle/CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle/AssemblyVisibility.cs b/src/bundle/CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle/AssemblyVisibility.cs new file mode 100644 index 00000000..ed166fcc --- /dev/null +++ b/src/bundle/CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/AssemblyVisibility.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/AssemblyVisibility.cs new file mode 100644 index 00000000..ed166fcc --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Bundle/UAuthDbContextTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bundle/UAuthDbContextTests.cs new file mode 100644 index 00000000..5fff3bdb --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bundle/UAuthDbContextTests.cs @@ -0,0 +1,90 @@ +using CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Users.EntityFrameworkCore; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tests.Unit.EntityFrameworkCore; + +public sealed class UAuthDbContextTests +{ + [Fact] + public void Model_ShouldContainAllUltimateAuthProjectionTypes() + { + var options = + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + using var context = new UAuthDbContext(options); + + var model = context.Model; + + model.FindEntityType(typeof(UserLifecycleProjection)) + .Should().NotBeNull(); + + model.FindEntityType(typeof(UserProfileProjection)) + .Should().NotBeNull(); + + model.FindEntityType(typeof(UserIdentifierProjection)) + .Should().NotBeNull(); + + model.FindEntityType(typeof(PasswordCredentialProjection)) + .Should().NotBeNull(); + + model.FindEntityType(typeof(RoleProjection)) + .Should().NotBeNull(); + + model.FindEntityType(typeof(RolePermissionProjection)) + .Should().NotBeNull(); + + model.FindEntityType(typeof(UserRoleProjection)) + .Should().NotBeNull(); + + model.FindEntityType(typeof(SessionRootProjection)) + .Should().NotBeNull(); + + model.FindEntityType(typeof(SessionChainProjection)) + .Should().NotBeNull(); + + model.FindEntityType(typeof(SessionProjection)) + .Should().NotBeNull(); + + model.FindEntityType(typeof(RefreshTokenProjection)) + .Should().NotBeNull(); + + model.FindEntityType(typeof(AuthenticationSecurityStateProjection)) + .Should().NotBeNull(); + } + + [Fact] + public void DbSets_ShouldBeAvailable() + { + var options = + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + using var context = new UAuthDbContext(options); + + context.UserLifecycles.Should().NotBeNull(); + context.UserProfiles.Should().NotBeNull(); + context.UserIdentifiers.Should().NotBeNull(); + context.PasswordCredentials.Should().NotBeNull(); + + context.Roles.Should().NotBeNull(); + context.UserRoleAssignments.Should().NotBeNull(); + context.UserPermissions.Should().NotBeNull(); + + context.Roots.Should().NotBeNull(); + context.Chains.Should().NotBeNull(); + context.Sessions.Should().NotBeNull(); + + context.RefreshTokens.Should().NotBeNull(); + context.AuthenticationSecurityStates.Should().NotBeNull(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Bundle/UAuthEfCoreOptionsTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bundle/UAuthEfCoreOptionsTests.cs new file mode 100644 index 00000000..65d899eb --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bundle/UAuthEfCoreOptionsTests.cs @@ -0,0 +1,52 @@ +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tests.Unit.EntityFrameworkCore; + +public sealed class UAuthEfCoreOptionsTests +{ + [Fact] + public void Resolve_WithSpecificConfiguration_ShouldReturnSpecific() + { + Action defaultConfig = _ => { }; + Action specificConfig = _ => { }; + + var options = new UAuthEfCoreOptions + { + Default = defaultConfig + }; + + var result = options.Resolve(specificConfig); + + result.Should().BeSameAs(specificConfig); + } + + [Fact] + public void Resolve_WithoutSpecificConfiguration_ShouldReturnDefault() + { + Action defaultConfig = _ => { }; + + var options = new UAuthEfCoreOptions + { + Default = defaultConfig + }; + + var result = options.Resolve(null); + + result.Should().BeSameAs(defaultConfig); + } + + [Fact] + public void Resolve_WithoutAnyConfiguration_ShouldThrow() + { + var options = new UAuthEfCoreOptions(); + + var act = () => options.Resolve(null); + + act.Should() + .Throw() + .WithMessage( + "No database configuration provided for UltimateAuth EFCore.*"); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Bundle/UltimateAuthEntityFrameworkCoreExtensionsTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bundle/UltimateAuthEntityFrameworkCoreExtensionsTests.cs new file mode 100644 index 00000000..babbafe1 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bundle/UltimateAuthEntityFrameworkCoreExtensionsTests.cs @@ -0,0 +1,130 @@ +using CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Users.EntityFrameworkCore; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Unit.EntityFrameworkCore; + +public sealed class UltimateAuthEntityFrameworkCoreExtensionsTests +{ + [Fact] + public void AddUltimateAuthEntityFrameworkCore_WithUnifiedContext_ShouldRegisterUAuthDbContext() + { + var services = new ServiceCollection(); + + services.AddUltimateAuthEntityFrameworkCore(options => + options.UseInMemoryDatabase(Guid.NewGuid().ToString())); + + using var provider = services.BuildServiceProvider(); + + using var scope = provider.CreateScope(); + + var context = + scope.ServiceProvider.GetRequiredService(); + + context.Should().NotBeNull(); + } + + [Fact] + public void AddUltimateAuthEntityFrameworkCore_WithUnifiedContext_ShouldConfigureDatabaseProvider() + { + var services = new ServiceCollection(); + + services.AddUltimateAuthEntityFrameworkCore(options => + options.UseInMemoryDatabase("uauth-test")); + + using var provider = services.BuildServiceProvider(); + + using var scope = provider.CreateScope(); + + var context = + scope.ServiceProvider.GetRequiredService(); + + context.Database.ProviderName + .Should() + .Be("Microsoft.EntityFrameworkCore.InMemory"); + } + + [Fact] + public void AddUltimateAuthEntityFrameworkCore_WithDefaultConfiguration_ShouldResolveAllContexts() + { + var services = new ServiceCollection(); + + services.AddUltimateAuthEntityFrameworkCore(options => + { + options.Default = builder => + builder.UseInMemoryDatabase( + Guid.NewGuid().ToString()); + }); + + using var provider = services.BuildServiceProvider(); + + using var scope = provider.CreateScope(); + + scope.ServiceProvider + .GetRequiredService() + .Should().NotBeNull(); + + scope.ServiceProvider + .GetRequiredService() + .Should().NotBeNull(); + + scope.ServiceProvider + .GetRequiredService() + .Should().NotBeNull(); + + scope.ServiceProvider + .GetRequiredService() + .Should().NotBeNull(); + + scope.ServiceProvider + .GetRequiredService() + .Should().NotBeNull(); + + scope.ServiceProvider + .GetRequiredService() + .Should().NotBeNull(); + } + + [Fact] + public void AddUltimateAuthEntityFrameworkCore_SpecificConfiguration_ShouldOverrideDefault() + { + var services = new ServiceCollection(); + + services.AddUltimateAuthEntityFrameworkCore(options => + { + options.Default = builder => + builder.UseSqlite("Data Source=default.db"); + + options.Users = builder => + builder.UseSqlite("Data Source=users.db"); + }); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + + var users = + scope.ServiceProvider + .GetRequiredService(); + + var sessions = + scope.ServiceProvider + .GetRequiredService(); + + users.Database.GetDbConnection() + .DataSource + .Should() + .Be("users.db"); + + sessions.Database.GetDbConnection() + .DataSource + .Should() + .Be("default.db"); + } +} From 960262d7bceaa9512df1acf9d1ab8e0c116ef13a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 5 Oct 2026 23:06:06 +0300 Subject: [PATCH 11/16] Cleanup & Server Authentication & Authorization (Asp Net Core Compatibility) Tests --- .../Abstractions/Auth/IAuthContextFactory.cs | 12 +- .../Auth/Context/AuthContextFactory.cs | 38 +- .../EffectiveLogoutRedirectResponse.cs | 8 - .../AuthenticationSecurityManager.cs | 2 - .../UAuthResourceAccessOrchestrator.cs | 12 +- .../Diagnostics/UAuthDiagnostic.cs | 16 +- .../Diagnostics/UAuthStartupDiagnostics.cs | 112 +-- .../Extensions/ServiceCollectionExtensions.cs | 4 +- .../ResourceApi/ResourceAuthContextFactory.cs | 112 +-- .../Extensions/CredentialTypeParser.cs | 36 - .../Responses/CredentialChangeResult.cs | 13 - .../Responses/CredentialProvisionResult.cs | 41 -- .../Infrastructure/SessionChainIdConverter.cs | 14 - .../InMemory/InMemoryVersionedStoreTests.cs | 337 +++++++++ .../AuthenticationSecurityManagerTests.cs | 684 ++++++++++++++++++ .../ResourceAccessContextBuilderTests.cs | 188 +++++ .../UAuthAuthenticationExtensionsTests.cs | 83 +++ .../Server/UAuthAuthorizationHandlerTests.cs | 219 ++++++ .../Server/UAuthPolicyProviderTests.cs | 93 +++ .../UAuthResourceAccessOrchestratorTests.cs | 432 +++++++++++ ...UAuthResourceAuthenticationHandlerTests.cs | 470 ++++++++++++ 21 files changed, 2659 insertions(+), 267 deletions(-) delete mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLogoutRedirectResponse.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialChangeResult.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs delete mode 100644 src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/SessionChainIdConverter.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/InMemory/InMemoryVersionedStoreTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AuthenticationSecurityManagerTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ResourceAccessContextBuilderTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthAuthenticationExtensionsTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthAuthorizationHandlerTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthPolicyProviderTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAuthenticationHandlerTests.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Auth/IAuthContextFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Auth/IAuthContextFactory.cs index 1dc892e9..284584fb 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Auth/IAuthContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Auth/IAuthContextFactory.cs @@ -1,8 +1,8 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +//using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Core.Abstractions; +//namespace CodeBeam.UltimateAuth.Core.Abstractions; -public interface IAuthContextFactory -{ - AuthContext Create(DateTimeOffset? at = null); -} +//public interface IAuthContextFactory +//{ +// AuthContext Create(DateTimeOffset? at = null); +//} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthContextFactory.cs index 948cfc8f..2a554a29 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthContextFactory.cs @@ -1,23 +1,23 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Extensions; +//using CodeBeam.UltimateAuth.Core.Abstractions; +//using CodeBeam.UltimateAuth.Core.Contracts; +//using CodeBeam.UltimateAuth.Server.Extensions; -namespace CodeBeam.UltimateAuth.Server.Auth; +//namespace CodeBeam.UltimateAuth.Server.Auth; -internal sealed class AuthContextFactory : IAuthContextFactory -{ - private readonly IAuthFlowContextAccessor _flow; - private readonly IClock _clock; +//internal sealed class AuthContextFactory : IAuthContextFactory +//{ +// private readonly IAuthFlowContextAccessor _flow; +// private readonly IClock _clock; - public AuthContextFactory(IAuthFlowContextAccessor flow, IClock clock) - { - _flow = flow; - _clock = clock; - } +// public AuthContextFactory(IAuthFlowContextAccessor flow, IClock clock) +// { +// _flow = flow; +// _clock = clock; +// } - public AuthContext Create(DateTimeOffset? at = null) - { - var flow = _flow.Current; - return flow.ToAuthContext(at ?? _clock.UtcNow); - } -} +// public AuthContext Create(DateTimeOffset? at = null) +// { +// var flow = _flow.Current; +// return flow.ToAuthContext(at ?? _clock.UtcNow); +// } +//} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLogoutRedirectResponse.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLogoutRedirectResponse.cs deleted file mode 100644 index f042e790..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLogoutRedirectResponse.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Auth; - -public sealed record EffectiveLogoutRedirectResponse -( - bool RedirectEnabled, - string RedirectPath, - bool AllowReturnUrlOverride -); diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs index cb630b62..a449b3dc 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs @@ -3,8 +3,6 @@ using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Security; -using CodeBeam.UltimateAuth.Server.Options; -using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Server.Security; diff --git a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthResourceAccessOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthResourceAccessOrchestrator.cs index 302b4327..4774e393 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthResourceAccessOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthResourceAccessOrchestrator.cs @@ -34,12 +34,12 @@ public async Task ExecuteAsync(AccessContext context, IAccessCommand command, Ca var policies = _policyProvider.GetPolicies(context); var decision = _authority.Decide(context, policies); - if (!decision.IsAllowed) - throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied"); - if (decision.RequiresReauthentication) throw new InvalidOperationException("Requires reauthentication."); + if (decision.IsDenied) + throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied"); + await command.ExecuteAsync(ct); } @@ -52,12 +52,12 @@ public async Task ExecuteAsync(AccessContext context, IAccessC var policies = _policyProvider.GetPolicies(context); var decision = _authority.Decide(context, policies); - if (!decision.IsAllowed) - throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied"); - if (decision.RequiresReauthentication) throw new InvalidOperationException("Requires reauthentication."); + if (decision.IsDenied) + throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied"); + return await command.ExecuteAsync(ct); } diff --git a/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthDiagnostic.cs b/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthDiagnostic.cs index 386388b0..3dfa860d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthDiagnostic.cs +++ b/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthDiagnostic.cs @@ -1,10 +1,10 @@ -namespace CodeBeam.UltimateAuth.Server.Diagnostics; +//namespace CodeBeam.UltimateAuth.Server.Diagnostics; -public sealed record UAuthDiagnostic(string code, string message, UAuthDiagnosticSeverity severity); +//public sealed record UAuthDiagnostic(string code, string message, UAuthDiagnosticSeverity severity); -public enum UAuthDiagnosticSeverity -{ - Info = 0, - Warning = 10, - Error = 20 -} +//public enum UAuthDiagnosticSeverity +//{ +// Info = 0, +// Warning = 10, +// Error = 20 +//} diff --git a/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthStartupDiagnostics.cs b/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthStartupDiagnostics.cs index c43e7083..ae248b2f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthStartupDiagnostics.cs +++ b/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthStartupDiagnostics.cs @@ -1,57 +1,57 @@ -using CodeBeam.UltimateAuth.Server.Options; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Diagnostics; - -internal static class UAuthStartupDiagnostics -{ - public static IEnumerable Analyze(UAuthServerOptions options) - { - foreach (var d in AnalyzeCookies(options)) - yield return d; - } - - private static IEnumerable AnalyzeCookies(UAuthServerOptions options) - { - if (options.HubDeploymentMode != UAuthHubDeploymentMode.External) - yield break; - - var session = options.Cookie.Session; - - if (session.SameSite == SameSiteMode.None && - session.SecurePolicy != CookieSecurePolicy.Always) - { - yield return new UAuthDiagnostic( - code: "UAUTH001", - message: - "Session cookie uses SameSite=None without Secure in External deployment. " + - "This is insecure and may expose authentication to network attackers.", - severity: UAuthDiagnosticSeverity.Error); - } - - var refresh = options.Cookie.RefreshToken; - - if (refresh.SameSite == SameSiteMode.None && - refresh.SecurePolicy != CookieSecurePolicy.Always) - { - yield return new UAuthDiagnostic( - code: "UAUTH002", - message: - "Refresh token cookie uses SameSite=None without Secure in External deployment. " + - "This is a critical security risk and MUST NOT be used outside development.", - severity: UAuthDiagnosticSeverity.Error); - } - - // TODO: Think again with MAUI. - if (!refresh.HttpOnly) - { - yield return new UAuthDiagnostic( - code: "UAUTH003", - message: - "Refresh token cookie is not HttpOnly. This allows JavaScript access and " + - "significantly increases the impact of XSS vulnerabilities.", - severity: UAuthDiagnosticSeverity.Warning); - } - } -} +//using CodeBeam.UltimateAuth.Server.Options; +//using Microsoft.AspNetCore.Http; + +//namespace CodeBeam.UltimateAuth.Server.Diagnostics; + +//internal static class UAuthStartupDiagnostics +//{ +// public static IEnumerable Analyze(UAuthServerOptions options) +// { +// foreach (var d in AnalyzeCookies(options)) +// yield return d; +// } + +// private static IEnumerable AnalyzeCookies(UAuthServerOptions options) +// { +// if (options.HubDeploymentMode != UAuthHubDeploymentMode.External) +// yield break; + +// var session = options.Cookie.Session; + +// if (session.SameSite == SameSiteMode.None && +// session.SecurePolicy != CookieSecurePolicy.Always) +// { +// yield return new UAuthDiagnostic( +// code: "UAUTH001", +// message: +// "Session cookie uses SameSite=None without Secure in External deployment. " + +// "This is insecure and may expose authentication to network attackers.", +// severity: UAuthDiagnosticSeverity.Error); +// } + +// var refresh = options.Cookie.RefreshToken; + +// if (refresh.SameSite == SameSiteMode.None && +// refresh.SecurePolicy != CookieSecurePolicy.Always) +// { +// yield return new UAuthDiagnostic( +// code: "UAUTH002", +// message: +// "Refresh token cookie uses SameSite=None without Secure in External deployment. " + +// "This is a critical security risk and MUST NOT be used outside development.", +// severity: UAuthDiagnosticSeverity.Error); +// } + +// // TODO: Think again with MAUI. +// if (!refresh.HttpOnly) +// { +// yield return new UAuthDiagnostic( +// code: "UAUTH003", +// message: +// "Refresh token cookie is not HttpOnly. This allows JavaScript access and " + +// "significantly increases the impact of XSS vulnerabilities.", +// severity: UAuthDiagnosticSeverity.Warning); +// } +// } +//} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index 95b36ba5..6eb0f02d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -206,7 +206,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); - services.TryAddScoped(); + //services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); @@ -415,7 +415,7 @@ private static IServiceCollection AddUltimateAuthResourceInternal(this IServiceC services.AddScoped(); services.AddScoped, ResourceUserAccessor>(); - services.AddScoped(); + //services.AddScoped(); services.AddScoped(); // Server & Resource API Shared diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceAuthContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceAuthContextFactory.cs index 7d0ced82..b9811f0d 100644 --- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceAuthContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceAuthContextFactory.cs @@ -1,65 +1,65 @@ -using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Defaults; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Options; -using Microsoft.AspNetCore.Http; +//using CodeBeam.UltimateAuth.Core; +//using CodeBeam.UltimateAuth.Core.Abstractions; +//using CodeBeam.UltimateAuth.Core.Contracts; +//using CodeBeam.UltimateAuth.Core.Defaults; +//using CodeBeam.UltimateAuth.Core.Domain; +//using CodeBeam.UltimateAuth.Core.Options; +//using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.ResourceApi; +//namespace CodeBeam.UltimateAuth.Server.ResourceApi; -internal sealed class ResourceAuthContextFactory : IAuthContextFactory -{ - private readonly IHttpContextAccessor _http; - private readonly IClock _clock; +//internal sealed class ResourceAuthContextFactory : IAuthContextFactory +//{ +// private readonly IHttpContextAccessor _http; +// private readonly IClock _clock; - public ResourceAuthContextFactory(IHttpContextAccessor http, IClock clock) - { - _http = http; - _clock = clock; - } +// public ResourceAuthContextFactory(IHttpContextAccessor http, IClock clock) +// { +// _http = http; +// _clock = clock; +// } - public AuthContext Create(DateTimeOffset? at = null) - { - var ctx = _http.HttpContext!; +// public AuthContext Create(DateTimeOffset? at = null) +// { +// var ctx = _http.HttpContext!; - var result = ctx.Items[UAuthConstants.HttpItems.SessionValidationResult] as SessionValidationResult; +// var result = ctx.Items[UAuthConstants.HttpItems.SessionValidationResult] as SessionValidationResult; - DeviceContext device = result?.BoundDeviceId is { } deviceId - ? DeviceContext.Create(DeviceId.Create(deviceId.Value)) - : DeviceContext.Anonymous(); +// DeviceContext device = result?.BoundDeviceId is { } deviceId +// ? DeviceContext.Create(DeviceId.Create(deviceId.Value)) +// : DeviceContext.Anonymous(); - if (result is null || !result.IsValid) - { - return new AuthContext - { - Tenant = default!, - Operation = AuthOperation.ResourceAccess, - Mode = UAuthMode.PureOpaque, - ClientProfile = UAuthClientProfile.Api, - Device = device, - At = at ?? _clock.UtcNow, - Session = null - }; - } +// if (result is null || !result.IsValid) +// { +// return new AuthContext +// { +// Tenant = default!, +// Operation = AuthOperation.ResourceAccess, +// Mode = UAuthMode.PureOpaque, +// ClientProfile = UAuthClientProfile.Api, +// Device = device, +// At = at ?? _clock.UtcNow, +// Session = null +// }; +// } - return new AuthContext - { - Tenant = result.Tenant, - Operation = AuthOperation.ResourceAccess, - Mode = UAuthMode.PureOpaque, // TODO: Think about resolver. - ClientProfile = UAuthClientProfile.Api, - Device = device, - At = at ?? _clock.UtcNow, +// return new AuthContext +// { +// Tenant = result.Tenant, +// Operation = AuthOperation.ResourceAccess, +// Mode = UAuthMode.PureOpaque, // TODO: Think about resolver. +// ClientProfile = UAuthClientProfile.Api, +// Device = device, +// At = at ?? _clock.UtcNow, - Session = new SessionSecurityContext - { - UserKey = result.UserKey, - SessionId = result.SessionId!.Value, - State = result.State, - ChainId = result.ChainId, - BoundDeviceId = result.BoundDeviceId - } - }; - } -} \ No newline at end of file +// Session = new SessionSecurityContext +// { +// UserKey = result.UserKey, +// SessionId = result.SessionId!.Value, +// State = result.State, +// ChainId = result.ChainId, +// BoundDeviceId = result.BoundDeviceId +// } +// }; +// } +//} \ No newline at end of file diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs deleted file mode 100644 index d75b3543..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs +++ /dev/null @@ -1,36 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Credentials.Contracts; - -public static class CredentialTypeParser -{ - private static readonly Dictionary _map = - new(StringComparer.OrdinalIgnoreCase) - { - ["password"] = CredentialType.Password, - - ["otp"] = CredentialType.OneTimeCode, - ["one-time-code"] = CredentialType.OneTimeCode, - - ["email-otp"] = CredentialType.EmailOtp, - ["sms-otp"] = CredentialType.SmsOtp, - - ["totp"] = CredentialType.Totp, - - ["passkey"] = CredentialType.Passkey, - - ["certificate"] = CredentialType.Certificate, - ["cert"] = CredentialType.Certificate, - - ["api-key"] = CredentialType.ApiKey, - ["apikey"] = CredentialType.ApiKey, - - ["external"] = CredentialType.External - }; - - public static bool TryParse(string value, out CredentialType type) => _map.TryGetValue(value, out type); - - public static CredentialType ParseOrThrow(string value) => TryParse(value, out var type) - ? type - : throw new InvalidOperationException($"Unsupported credential type: '{value}'"); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialChangeResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialChangeResult.cs deleted file mode 100644 index ad00b575..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialChangeResult.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; - -public sealed record CredentialChangeResult -{ - public bool Succeeded { get; init; } - - /// - /// Indicates whether security version / sessions were invalidated. - /// - public bool SecurityInvalidated { get; init; } - - public string? FailureReason { get; init; } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs deleted file mode 100644 index c116b8e6..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs +++ /dev/null @@ -1,41 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Credentials.Contracts; - -public sealed record CredentialProvisionResult -{ - public required bool Succeeded { get; init; } - - public CredentialType? Type { get; init; } - - /// - /// Indicates whether existing security state was affected. - /// For initial provisioning this is usually false. - /// - public bool SecurityInvalidated { get; init; } - - public string? FailureReason { get; init; } - - public static CredentialProvisionResult Success(CredentialType type) - => new() - { - Succeeded = true, - Type = type, - SecurityInvalidated = false - }; - - public static CredentialProvisionResult AlreadyExists(CredentialType type) - => new() - { - Succeeded = true, - Type = type, - SecurityInvalidated = false - }; - - public static CredentialProvisionResult Failed(string reason) - => new() - { - Succeeded = false, - FailureReason = reason - }; -} diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/SessionChainIdConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/SessionChainIdConverter.cs deleted file mode 100644 index ff21b301..00000000 --- a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/SessionChainIdConverter.cs +++ /dev/null @@ -1,14 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -namespace CodeBeam.UltimateAuth.EntityFrameworkCore; - -public sealed class SessionChainIdConverter : ValueConverter -{ - public SessionChainIdConverter() - : base( - id => SessionChainIdEfConverter.ToDatabase(id), - raw => SessionChainIdEfConverter.FromDatabase(raw)) - { - } -} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/InMemory/InMemoryVersionedStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/InMemory/InMemoryVersionedStoreTests.cs new file mode 100644 index 00000000..f8d64643 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/InMemory/InMemoryVersionedStoreTests.cs @@ -0,0 +1,337 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.InMemory; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit.InMemory; + +public sealed class InMemoryVersionedStoreTests +{ + [Fact] + public async Task AddAsync_WithoutAtomicContext_ShouldPersistEntity() + { + var accessor = new InMemoryAtomicContextAccessor(); + var store = new TestStore(accessor); + + var entity = TestEntity.Create("entity-1", "initial"); + + await store.AddAsync(entity); + + var stored = await store.GetAsync("entity-1"); + + stored.Should().NotBeNull(); + stored!.Value.Should().Be("initial"); + stored.Version.Should().Be(0); + } + + [Fact] + public async Task AddAsync_WithAtomicContext_WhenRolledBack_ShouldRemoveEntity() + { + var accessor = new InMemoryAtomicContextAccessor(); + var atomic = new InMemoryAtomicContext(); + + accessor.Current = atomic; + + var store = new TestStore(accessor); + + await store.AddAsync( + TestEntity.Create("entity-1", "initial")); + + (await store.ExistsAsync("entity-1")) + .Should() + .BeTrue(); + + atomic.Rollback(); + + (await store.ExistsAsync("entity-1")) + .Should() + .BeFalse(); + } + + [Fact] + public async Task SaveAsync_WithAtomicContext_WhenRolledBack_ShouldRestorePreviousEntity() + { + var accessor = new InMemoryAtomicContextAccessor(); + var store = new TestStore(accessor); + + await store.AddAsync( + TestEntity.Create("entity-1", "initial")); + + var atomic = new InMemoryAtomicContext(); + accessor.Current = atomic; + + var entity = await store.GetAsync("entity-1"); + + entity!.Value = "updated"; + + await store.SaveAsync( + entity, + expectedVersion: 0); + + var updated = await store.GetAsync("entity-1"); + + updated!.Value.Should().Be("updated"); + updated.Version.Should().Be(1); + + atomic.Rollback(); + + var restored = await store.GetAsync("entity-1"); + + restored.Should().NotBeNull(); + restored!.Value.Should().Be("initial"); + restored.Version.Should().Be(0); + } + + [Fact] + public async Task DeleteAsync_Hard_WithAtomicContext_WhenRolledBack_ShouldRestoreEntity() + { + var accessor = new InMemoryAtomicContextAccessor(); + var store = new TestStore(accessor); + + await store.AddAsync( + TestEntity.Create("entity-1", "initial")); + + var atomic = new InMemoryAtomicContext(); + accessor.Current = atomic; + + await store.DeleteAsync( + "entity-1", + expectedVersion: 0, + DeleteMode.Hard, + DateTimeOffset.UtcNow); + + (await store.ExistsAsync("entity-1")) + .Should() + .BeFalse(); + + atomic.Rollback(); + + var restored = await store.GetAsync("entity-1"); + + restored.Should().NotBeNull(); + restored!.Value.Should().Be("initial"); + restored.Version.Should().Be(0); + restored.IsDeleted.Should().BeFalse(); + } + + [Fact] + public async Task DeleteAsync_Soft_WithAtomicContext_WhenRolledBack_ShouldRestoreEntity() + { + var accessor = new InMemoryAtomicContextAccessor(); + var store = new TestStore(accessor); + + await store.AddAsync( + TestEntity.Create("entity-1", "initial")); + + var atomic = new InMemoryAtomicContext(); + accessor.Current = atomic; + + var deletedAt = + new DateTimeOffset( + 2026, 1, 1, + 12, 0, 0, + TimeSpan.Zero); + + await store.DeleteAsync( + "entity-1", + expectedVersion: 0, + DeleteMode.Soft, + deletedAt); + + var deleted = await store.GetAsync("entity-1"); + + deleted.Should().NotBeNull(); + deleted!.IsDeleted.Should().BeTrue(); + deleted.DeletedAt.Should().Be(deletedAt); + deleted.Version.Should().Be(1); + + atomic.Rollback(); + + var restored = await store.GetAsync("entity-1"); + + restored.Should().NotBeNull(); + restored!.IsDeleted.Should().BeFalse(); + restored.DeletedAt.Should().BeNull(); + restored.Version.Should().Be(0); + } + + [Fact] + public async Task MultipleOperations_WhenRolledBack_ShouldRestoreOriginalState() + { + var accessor = new InMemoryAtomicContextAccessor(); + var store = new TestStore(accessor); + + await store.AddAsync( + TestEntity.Create("existing", "original")); + + var atomic = new InMemoryAtomicContext(); + accessor.Current = atomic; + + // ADD + await store.AddAsync( + TestEntity.Create("new", "created")); + + // UPDATE + var existing = await store.GetAsync("existing"); + + existing!.Value = "updated"; + + await store.SaveAsync( + existing, + expectedVersion: 0); + + // DELETE newly-created entity + await store.DeleteAsync( + "new", + expectedVersion: 0, + DeleteMode.Hard, + DateTimeOffset.UtcNow); + + atomic.Rollback(); + + var restoredExisting = + await store.GetAsync("existing"); + + restoredExisting.Should().NotBeNull(); + restoredExisting!.Value.Should().Be("original"); + restoredExisting.Version.Should().Be(0); + + (await store.ExistsAsync("new")) + .Should() + .BeFalse(); + } + + [Fact] + public void Rollback_ShouldExecuteActionsInReverseOrder() + { + var atomic = new InMemoryAtomicContext(); + + var calls = new List(); + + atomic.RegisterRollback(() => calls.Add(1)); + atomic.RegisterRollback(() => calls.Add(2)); + atomic.RegisterRollback(() => calls.Add(3)); + + atomic.Rollback(); + + calls.Should().ContainInOrder(3, 2, 1); + } + + [Fact] + public void Rollback_WhenActionFails_ShouldContinueExecutingRemainingActions() + { + var atomic = new InMemoryAtomicContext(); + + var calls = new List(); + + atomic.RegisterRollback( + () => calls.Add(1)); + + atomic.RegisterRollback( + () => throw new InvalidOperationException("rollback failed")); + + atomic.RegisterRollback( + () => calls.Add(3)); + + var act = atomic.Rollback; + + act.Should() + .Throw() + .Which.InnerExceptions.Should() + .ContainSingle() + .Which.Message.Should() + .Be("rollback failed"); + + calls.Should().ContainInOrder(3, 1); + } + + [Fact] + public void RegisterRollback_WithNullAction_ShouldThrow() + { + var atomic = new InMemoryAtomicContext(); + + var act = () => + atomic.RegisterRollback(null!); + + act.Should() + .Throw(); + } + + [Fact] + public async Task AtomicContextAccessor_ShouldFlowAcrossAwait() + { + var accessor = new InMemoryAtomicContextAccessor(); + var atomic = new InMemoryAtomicContext(); + + accessor.Current = atomic; + + await Task.Yield(); + + accessor.Current.Should().BeSameAs(atomic); + } + + private sealed class TestStore + : InMemoryVersionedStore + { + public TestStore( + InMemoryAtomicContextAccessor atomicContext) + : base(atomicContext) + { + } + + protected override string GetKey( + TestEntity entity) + => entity.Id; + } + + private sealed class TestEntity : + IVersionedEntity, + IEntitySnapshot, + ISoftDeletable + { + public required string Id { get; init; } + + public required string Value { get; set; } + + public long Version { get; set; } + + public bool IsDeleted { get; private set; } + + public DateTimeOffset? DeletedAt { get; private set; } + + public static TestEntity Create( + string id, + string value) + { + return new TestEntity + { + Id = id, + Value = value, + Version = 0 + }; + } + + public TestEntity Snapshot() + { + return new TestEntity + { + Id = Id, + Value = Value, + Version = Version, + IsDeleted = IsDeleted, + DeletedAt = DeletedAt + }; + } + + public TestEntity MarkDeleted( + DateTimeOffset now) + { + var snapshot = Snapshot(); + + snapshot.IsDeleted = true; + snapshot.DeletedAt = now; + + return snapshot; + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AuthenticationSecurityManagerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AuthenticationSecurityManagerTests.cs new file mode 100644 index 00000000..3359f009 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AuthenticationSecurityManagerTests.cs @@ -0,0 +1,684 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Security; +using CodeBeam.UltimateAuth.Server.Security; +using FluentAssertions; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server.Security; + +public sealed class AuthenticationSecurityManagerTests +{ + private readonly Mock _storeFactory; + private readonly Mock _store; + + private readonly AuthenticationSecurityManager _sut; + + private readonly TenantKey _tenant = + TenantKey.FromExternal("tenant-1"); + + private readonly UserKey _userKey = + UserKey.FromString("user-1"); + + public AuthenticationSecurityManagerTests() + { + _storeFactory = + new Mock(); + + _store = + new Mock(); + + _storeFactory + .Setup(x => x.Create(_tenant)) + .Returns(_store.Object); + + _sut = new AuthenticationSecurityManager( + _storeFactory.Object); + } + + // --------------------------------------------------------------------- + // GetOrCreateAccountAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task GetOrCreateAccountAsync_WhenStateExists_ShouldReturnExistingState() + { + var existing = + AuthenticationSecurityState.CreateAccount( + _tenant, + _userKey); + + _store + .Setup(x => x.GetAsync( + _userKey, + AuthenticationSecurityScope.Account, + null, + It.IsAny())) + .ReturnsAsync(existing); + + var result = await _sut.GetOrCreateAccountAsync( + _tenant, + _userKey); + + result.Should().BeSameAs(existing); + + _store.Verify( + x => x.AddAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task GetOrCreateAccountAsync_WhenStateDoesNotExist_ShouldCreateState() + { + _store + .Setup(x => x.GetAsync( + _userKey, + AuthenticationSecurityScope.Account, + null, + It.IsAny())) + .ReturnsAsync((AuthenticationSecurityState?)null); + + AuthenticationSecurityState? added = null; + + _store + .Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (state, _) => added = state) + .Returns(Task.CompletedTask); + + var result = await _sut.GetOrCreateAccountAsync( + _tenant, + _userKey); + + result.Should().NotBeNull(); + result.Tenant.Should().Be(_tenant); + result.UserKey.Should().Be(_userKey); + result.Scope.Should() + .Be(AuthenticationSecurityScope.Account); + + added.Should().BeSameAs(result); + } + + [Fact] + public async Task GetOrCreateAccountAsync_WhenConcurrentCreationOccurs_ShouldReturnExistingState() + { + var existing = + AuthenticationSecurityState.CreateAccount( + _tenant, + _userKey); + + var call = 0; + + _store + .Setup(x => x.GetAsync( + _userKey, + AuthenticationSecurityScope.Account, + null, + It.IsAny())) + .ReturnsAsync(() => + { + call++; + + return call == 1 + ? null + : existing; + }); + + _store + .Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .ThrowsAsync( + new UAuthConflictException("concurrent creation")); + + var result = await _sut.GetOrCreateAccountAsync( + _tenant, + _userKey); + + result.Should().BeSameAs(existing); + + _store.Verify( + x => x.GetAsync( + _userKey, + AuthenticationSecurityScope.Account, + null, + It.IsAny()), + Times.Exactly(2)); + } + + [Fact] + public async Task GetOrCreateAccountAsync_WhenConflictOccursAndStateStillDoesNotExist_ShouldRethrowConflict() + { + _store + .Setup(x => x.GetAsync( + _userKey, + AuthenticationSecurityScope.Account, + null, + It.IsAny())) + .ReturnsAsync((AuthenticationSecurityState?)null); + + _store + .Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .ThrowsAsync( + new UAuthConflictException("conflict")); + + var act = () => + _sut.GetOrCreateAccountAsync( + _tenant, + _userKey); + + await act.Should() + .ThrowAsync(); + } + + // --------------------------------------------------------------------- + // GetOrCreateFactorAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task GetOrCreateFactorAsync_WhenStateExists_ShouldReturnExistingState() + { + var existing = + AuthenticationSecurityState.CreateFactor( + _tenant, + _userKey, + CredentialType.Password); + + _store + .Setup(x => x.GetAsync( + _userKey, + AuthenticationSecurityScope.Factor, + CredentialType.Password, + It.IsAny())) + .ReturnsAsync(existing); + + var result = await _sut.GetOrCreateFactorAsync( + _tenant, + _userKey, + CredentialType.Password); + + result.Should().BeSameAs(existing); + + _store.Verify( + x => x.AddAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task GetOrCreateFactorAsync_WhenStateDoesNotExist_ShouldCreateCorrectFactor() + { + _store + .Setup(x => x.GetAsync( + _userKey, + AuthenticationSecurityScope.Factor, + CredentialType.Password, + It.IsAny())) + .ReturnsAsync((AuthenticationSecurityState?)null); + + AuthenticationSecurityState? added = null; + + _store + .Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (state, _) => added = state) + .Returns(Task.CompletedTask); + + var result = await _sut.GetOrCreateFactorAsync( + _tenant, + _userKey, + CredentialType.Password); + + result.Tenant.Should().Be(_tenant); + result.UserKey.Should().Be(_userKey); + result.Scope.Should() + .Be(AuthenticationSecurityScope.Factor); + + result.CredentialType.Should() + .Be(CredentialType.Password); + + added.Should().BeSameAs(result); + } + + [Fact] + public async Task GetOrCreateFactorAsync_WhenConcurrentCreationOccurs_ShouldReturnExistingState() + { + var existing = + AuthenticationSecurityState.CreateFactor( + _tenant, + _userKey, + CredentialType.Password); + + var call = 0; + + _store + .Setup(x => x.GetAsync( + _userKey, + AuthenticationSecurityScope.Factor, + CredentialType.Password, + It.IsAny())) + .ReturnsAsync(() => + { + call++; + + return call == 1 + ? null + : existing; + }); + + _store + .Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .ThrowsAsync( + new UAuthConflictException("concurrent creation")); + + var result = await _sut.GetOrCreateFactorAsync( + _tenant, + _userKey, + CredentialType.Password); + + result.Should().BeSameAs(existing); + } + + // --------------------------------------------------------------------- + // MutateAccountAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task MutateAccountAsync_WhenMutationReturnsSameInstance_ShouldNotUpdateStore() + { + var existing = + AuthenticationSecurityState.CreateAccount( + _tenant, + _userKey); + + _store + .Setup(x => x.GetAsync( + _userKey, + AuthenticationSecurityScope.Account, + null, + It.IsAny())) + .ReturnsAsync(existing); + + var result = await _sut.MutateAccountAsync( + _tenant, + _userKey, + state => state); + + result.Should().BeSameAs(existing); + + _store.Verify( + x => x.UpdateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task MutateAccountAsync_WhenUpdateSucceeds_ShouldUseCurrentSecurityVersion() + { + var current = + AuthenticationSecurityState.CreateAccount( + _tenant, + _userKey); + + var updated = CreateMutatedState(current); + + _store + .Setup(x => x.GetAsync( + _userKey, + AuthenticationSecurityScope.Account, + null, + It.IsAny())) + .ReturnsAsync(current); + + _store + .Setup(x => x.UpdateAsync( + updated, + current.SecurityVersion, + It.IsAny())) + .Returns(Task.CompletedTask); + + var result = await _sut.MutateAccountAsync( + _tenant, + _userKey, + _ => updated); + + result.Should().BeSameAs(updated); + + _store.Verify( + x => x.UpdateAsync( + updated, + current.SecurityVersion, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task MutateAccountAsync_WhenUpdateConflicts_ShouldReloadAndReapplyMutation() + { + var first = + AuthenticationSecurityState.CreateAccount( + _tenant, + _userKey); + + var second = + AuthenticationSecurityState.CreateAccount( + _tenant, + _userKey); + + var getCall = 0; + var updateCall = 0; + var mutationCall = 0; + + _store + .Setup(x => x.GetAsync( + _userKey, + AuthenticationSecurityScope.Account, + null, + It.IsAny())) + .ReturnsAsync(() => + { + getCall++; + + return getCall == 1 + ? first + : second; + }); + + _store + .Setup(x => x.UpdateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns( + (_, _, _) => + { + updateCall++; + + if (updateCall == 1) + { + throw new UAuthConflictException( + "concurrent update"); + } + + return Task.CompletedTask; + }); + + var result = await _sut.MutateAccountAsync( + _tenant, + _userKey, + state => + { + mutationCall++; + return CreateMutatedState(state); + }); + + result.Should().NotBeNull(); + + mutationCall.Should().Be(2); + getCall.Should().Be(2); + updateCall.Should().Be(2); + } + + [Fact] + public async Task MutateAccountAsync_WhenAllFiveUpdatesConflict_ShouldStopAfterFiveAttempts() + { + var mutationCalls = 0; + + _store + .Setup(x => x.GetAsync( + _userKey, + AuthenticationSecurityScope.Account, + null, + It.IsAny())) + .ReturnsAsync(() => + AuthenticationSecurityState.CreateAccount( + _tenant, + _userKey)); + + _store + .Setup(x => x.UpdateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync( + new UAuthConflictException("conflict")); + + var act = () => + _sut.MutateAccountAsync( + _tenant, + _userKey, + state => + { + mutationCalls++; + return CreateMutatedState(state); + }); + + await act.Should() + .ThrowAsync(); + + mutationCalls.Should().Be(5); + + _store.Verify( + x => x.UpdateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Exactly(5)); + } + + // --------------------------------------------------------------------- + // MutateFactorAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task MutateFactorAsync_WhenMutationReturnsSameInstance_ShouldNotUpdateStore() + { + var existing = + AuthenticationSecurityState.CreateFactor( + _tenant, + _userKey, + CredentialType.Password); + + _store + .Setup(x => x.GetAsync( + _userKey, + AuthenticationSecurityScope.Factor, + CredentialType.Password, + It.IsAny())) + .ReturnsAsync(existing); + + var result = await _sut.MutateFactorAsync( + _tenant, + _userKey, + CredentialType.Password, + state => state); + + result.Should().BeSameAs(existing); + + _store.Verify( + x => x.UpdateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task MutateFactorAsync_WhenUpdateConflicts_ShouldReloadAndReapplyMutation() + { + var getCalls = 0; + var updateCalls = 0; + var mutationCalls = 0; + + _store + .Setup(x => x.GetAsync( + _userKey, + AuthenticationSecurityScope.Factor, + CredentialType.Password, + It.IsAny())) + .ReturnsAsync(() => + { + getCalls++; + + return AuthenticationSecurityState.CreateFactor( + _tenant, + _userKey, + CredentialType.Password); + }); + + _store + .Setup(x => x.UpdateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns( + (_, _, _) => + { + updateCalls++; + + if (updateCalls == 1) + { + throw new UAuthConflictException( + "concurrent update"); + } + + return Task.CompletedTask; + }); + + var result = await _sut.MutateFactorAsync( + _tenant, + _userKey, + CredentialType.Password, + state => + { + mutationCalls++; + return CreateMutatedState(state); + }); + + result.Should().NotBeNull(); + + getCalls.Should().Be(2); + mutationCalls.Should().Be(2); + updateCalls.Should().Be(2); + } + + // --------------------------------------------------------------------- + // Direct delegation + // --------------------------------------------------------------------- + + [Fact] + public async Task UpdateAsync_ShouldResolveStoreUsingUpdatedStateTenant() + { + var otherTenant = + TenantKey.FromExternal("tenant-2"); + + var otherStore = + new Mock(); + + _storeFactory + .Setup(x => x.Create(otherTenant)) + .Returns(otherStore.Object); + + var state = + AuthenticationSecurityState.CreateAccount( + otherTenant, + _userKey); + + await _sut.UpdateAsync( + state, + expectedVersion: 7); + + _storeFactory.Verify( + x => x.Create(otherTenant), + Times.Once); + + otherStore.Verify( + x => x.UpdateAsync( + state, + 7, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task DeleteAsync_ShouldResolveStoreUsingRequestedTenant() + { + var otherTenant = + TenantKey.FromExternal("tenant-2"); + + var otherStore = + new Mock(); + + _storeFactory + .Setup(x => x.Create(otherTenant)) + .Returns(otherStore.Object); + + await _sut.DeleteAsync( + otherTenant, + _userKey, + AuthenticationSecurityScope.Factor, + CredentialType.Password); + + _storeFactory.Verify( + x => x.Create(otherTenant), + Times.Once); + + otherStore.Verify( + x => x.DeleteAsync( + _userKey, + AuthenticationSecurityScope.Factor, + CredentialType.Password, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task GetOrCreateAccountAsync_WhenCancelled_ShouldNotAccessStore() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => + _sut.GetOrCreateAccountAsync( + _tenant, + _userKey, + cts.Token); + + await act.Should() + .ThrowAsync(); + + _storeFactory.Verify( + x => x.Create(It.IsAny()), + Times.Never); + } + + // --------------------------------------------------------------------- + // Helper + // --------------------------------------------------------------------- + + private static readonly DateTimeOffset MutationTime = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); + + private static AuthenticationSecurityState CreateMutatedState(AuthenticationSecurityState state) + { + return state.RegisterFailure( + now: MutationTime, + threshold: 5, + lockoutDuration: TimeSpan.FromMinutes(5), + failureWindow: TimeSpan.FromMinutes(15)); + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ResourceAccessContextBuilderTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ResourceAccessContextBuilderTests.cs new file mode 100644 index 00000000..c3eb38c0 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ResourceAccessContextBuilderTests.cs @@ -0,0 +1,188 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Authorization; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server.Authorization; + +public sealed class ResourceAccessContextBuilderTests +{ + [Fact] + public void Create_WithAuthenticatedUser_ShouldSetActor() + { + var userKey = CreateUserKey(); + + var http = CreateHttpContext( + authenticated: true, + userKey); + + var result = + ResourceAccessContextBuilder.Create( + http, + "users.read"); + + result.IsAuthenticated.Should().BeTrue(); + result.ActorUserKey.Should().Be(userKey); + result.Action.Should().Be("users.read"); + result.Resource.Should().Be("users"); + } + + [Fact] + public void Create_WithAnonymousUser_ShouldNotSetActorUserKey() + { + var http = CreateHttpContext( + authenticated: false); + + var result = + ResourceAccessContextBuilder.Create( + http, + "users.read"); + + result.IsAuthenticated.Should().BeFalse(); + result.ActorUserKey.Should().BeNull(); + } + + [Theory] + [InlineData("users.read", "users")] + [InlineData("users.delete", "users")] + [InlineData("users.security.sessions.revoke", "users")] + [InlineData("roles.assign", "roles")] + [InlineData("sessions.revoke", "sessions")] + public void Create_ShouldDeriveResourceFromAction( + string action, + string expectedResource) + { + var http = CreateHttpContext( + authenticated: false); + + var result = + ResourceAccessContextBuilder.Create( + http, + action); + + result.Resource.Should().Be(expectedResource); + result.Action.Should().Be(action); + } + + [Fact] + public void Create_ShouldNotTreatCurrentUserAsSystemActor() + { + var http = CreateHttpContext( + authenticated: true, + CreateUserKey()); + + var result = + ResourceAccessContextBuilder.Create( + http, + "users.read"); + + result.IsSystemActor.Should().BeFalse(); + } + + [Fact] + public void Create_ShouldNotSetTargetUser() + { + var http = CreateHttpContext( + authenticated: true, + CreateUserKey()); + + var result = + ResourceAccessContextBuilder.Create( + http, + "users.read"); + + result.TargetUserKey.Should().BeNull(); + } + + [Fact] + public void Create_ShouldPropagateTenantToActorAndResource() + { + var tenant = TenantKey.FromExternal("tenant-a"); + + var http = CreateHttpContext( + authenticated: true, + userKey: CreateUserKey(), + tenant: tenant); + + var result = + ResourceAccessContextBuilder.Create( + http, + "users.read"); + + result.ActorTenant.Should().Be(tenant); + result.ResourceTenant.Should().Be(tenant); + } + + [Fact] + public void Create_WithoutTenantContext_ShouldThrow() + { + var currentUser = new Mock(); + + currentUser + .SetupGet(x => x.IsAuthenticated) + .Returns(false); + + var services = new ServiceCollection(); + services.AddSingleton(currentUser.Object); + + var http = new DefaultHttpContext + { + RequestServices = services.BuildServiceProvider() + }; + + var act = () => + ResourceAccessContextBuilder.Create( + http, + "users.read"); + + act.Should() + .Throw() + .WithMessage("*TenantContext is missing*"); + } + + private static DefaultHttpContext CreateHttpContext(bool authenticated, UserKey? userKey = null, TenantKey? tenant = null) + { + var currentUser = new Mock(); + + currentUser + .SetupGet(x => x.IsAuthenticated) + .Returns(authenticated); + + if (authenticated) + { + if (userKey is null) + throw new ArgumentNullException(nameof(userKey)); + + currentUser + .SetupGet(x => x.UserKey) + .Returns(userKey.Value); + } + + var services = new ServiceCollection(); + + services.AddSingleton(currentUser.Object); + + var http = new DefaultHttpContext + { + RequestServices = services.BuildServiceProvider() + }; + + var resolvedTenant = + tenant ?? TenantKey.FromExternal("test-tenant"); + + http.Items[UAuthConstants.HttpItems.TenantContextKey] = + UAuthTenantContext.Resolved(resolvedTenant); + + return http; + } + + private static UserKey CreateUserKey() + { + return UserKey.New(); + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthAuthenticationExtensionsTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthAuthenticationExtensionsTests.cs new file mode 100644 index 00000000..95a738c4 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthAuthenticationExtensionsTests.cs @@ -0,0 +1,83 @@ +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Server.Authentication; +using FluentAssertions; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server; + +public sealed class UAuthAuthenticationExtensionsTests +{ + [Fact] + public async Task AddUAuthScheme_ShouldRegisterGlobalScheme() + { + var services = new ServiceCollection(); + + services.AddLogging(); + services + .AddAuthentication() + .AddUAuthScheme(); + + await using var provider = services.BuildServiceProvider(); + + var schemes = + provider.GetRequiredService(); + + var scheme = await schemes.GetSchemeAsync( + UAuthConstants.SchemeDefaults.GlobalScheme); + + scheme.Should().NotBeNull(); + scheme!.HandlerType.Should() + .Be(typeof(UAuthAuthenticationHandler)); + } + + [Fact] + public async Task AddUAuthResourceApi_ShouldRegisterGlobalScheme() + { + var services = new ServiceCollection(); + + services.AddLogging(); + services + .AddAuthentication() + .AddUAuthResourceApi(); + + await using var provider = services.BuildServiceProvider(); + + var schemes = + provider.GetRequiredService(); + + var scheme = await schemes.GetSchemeAsync( + UAuthConstants.SchemeDefaults.GlobalScheme); + + scheme.Should().NotBeNull(); + scheme!.HandlerType.Should() + .Be(typeof(UAuthResourceAuthenticationHandler)); + } + + [Fact] + public void AddUAuthScheme_ShouldApplyConfiguration() + { + var services = new ServiceCollection(); + + services.AddLogging(); + + services + .AddAuthentication() + .AddUAuthScheme(options => + { + options.ClaimsIssuer = "UltimateAuth.Tests"; + }); + + using var provider = services.BuildServiceProvider(); + + var options = + provider.GetRequiredService< + Microsoft.Extensions.Options.IOptionsMonitor< + UAuthAuthenticationSchemeOptions>>(); + + var configured = options.Get( + UAuthConstants.SchemeDefaults.GlobalScheme); + + configured.ClaimsIssuer.Should().Be("UltimateAuth.Tests"); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthAuthorizationHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthAuthorizationHandlerTests.cs new file mode 100644 index 00000000..4d0d6da9 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthAuthorizationHandlerTests.cs @@ -0,0 +1,219 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Authorization; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server.Authorization; + +public sealed class UAuthAuthorizationHandlerTests +{ + [Fact] + public async Task HandleAsync_WhenOrchestratorAllows_ShouldSucceedRequirement() + { + var orchestrator = new Mock(); + + var http = CreateHttpContext(false); + + var handler = new UAuthAuthorizationHandler( + orchestrator.Object, + new HttpContextAccessor + { + HttpContext = http + }); + + var requirement = + new UAuthActionRequirement("users.read"); + + var context = new AuthorizationHandlerContext( + new[] { requirement }, + user: http.User, + resource: null); + + await handler.HandleAsync(context); + + context.HasSucceeded.Should().BeTrue(); + + orchestrator.Verify( + x => x.ExecuteAsync( + It.Is(c => + c.Action == "users.read" && + c.Resource == "users"), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task HandleAsync_WhenOrchestratorDenies_ShouldNotSucceedRequirement() + { + var orchestrator = new Mock(); + + orchestrator + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync( + new UAuthAuthorizationException("denied")); + + var http = CreateHttpContext(false); + + var handler = new UAuthAuthorizationHandler( + orchestrator.Object, + new HttpContextAccessor + { + HttpContext = http + }); + + var requirement = + new UAuthActionRequirement("users.delete"); + + var context = new AuthorizationHandlerContext( + new[] { requirement }, + http.User, + resource: null); + + await handler.HandleAsync(context); + + context.HasSucceeded.Should().BeFalse(); + } + + [Fact] + public async Task HandleAsync_ShouldPreserveFullAction() + { + var orchestrator = new Mock(); + + AccessContext? captured = null; + + orchestrator + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (ctx, _, _) => captured = ctx) + .Returns(Task.CompletedTask); + + var http = CreateHttpContext(false); + + var handler = new UAuthAuthorizationHandler( + orchestrator.Object, + new HttpContextAccessor + { + HttpContext = http + }); + + var requirement = + new UAuthActionRequirement( + "users.security.sessions.revoke"); + + var context = new AuthorizationHandlerContext( + new[] { requirement }, + http.User, + resource: null); + + await handler.HandleAsync(context); + + captured.Should().NotBeNull(); + captured!.Action.Should() + .Be("users.security.sessions.revoke"); + + captured.Resource.Should().Be("users"); + } + + [Fact] + public async Task HandleAsync_WithAuthenticatedUser_ShouldPassActorToOrchestrator() + { + var orchestrator = new Mock(); + + AccessContext? captured = null; + + orchestrator + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (ctx, _, _) => captured = ctx) + .Returns(Task.CompletedTask); + + var userKey = UserKey.New(); + var tenant = TenantKey.FromExternal("tenant-a"); + + var http = CreateHttpContext( + authenticated: true, + userKey: userKey, + tenant: tenant); + + var handler = new UAuthAuthorizationHandler( + orchestrator.Object, + new HttpContextAccessor + { + HttpContext = http + }); + + var requirement = + new UAuthActionRequirement("users.read"); + + var context = new AuthorizationHandlerContext( + new[] { requirement }, + http.User, + resource: null); + + await handler.HandleAsync(context); + + captured.Should().NotBeNull(); + + captured!.IsAuthenticated.Should().BeTrue(); + captured.ActorUserKey.Should().Be(userKey); + captured.ActorTenant.Should().Be(tenant); + captured.ResourceTenant.Should().Be(tenant); + + context.HasSucceeded.Should().BeTrue(); + } + + private static DefaultHttpContext CreateHttpContext(bool authenticated, UserKey? userKey = null, TenantKey? tenant = null) + { + var currentUser = new Mock(); + + currentUser + .SetupGet(x => x.IsAuthenticated) + .Returns(authenticated); + + if (authenticated) + { + if (userKey is null) + throw new ArgumentNullException(nameof(userKey)); + + currentUser + .SetupGet(x => x.UserKey) + .Returns(userKey.Value); + } + + var services = new ServiceCollection(); + + services.AddSingleton(currentUser.Object); + + var http = new DefaultHttpContext + { + RequestServices = services.BuildServiceProvider() + }; + + var resolvedTenant = + tenant ?? TenantKey.FromExternal("test-tenant"); + + http.Items[UAuthConstants.HttpItems.TenantContextKey] = + UAuthTenantContext.Resolved(resolvedTenant); + + return http; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthPolicyProviderTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthPolicyProviderTests.cs new file mode 100644 index 00000000..7142cce9 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthPolicyProviderTests.cs @@ -0,0 +1,93 @@ +using CodeBeam.UltimateAuth.Server.Authorization; +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server.Authorization; + +public sealed class UAuthPolicyProviderTests +{ + [Fact] + public async Task GetPolicyAsync_ShouldCreateUAuthActionRequirement() + { + var sut = Create(); + + var policy = + await sut.GetPolicyAsync("users.delete"); + + policy.Should().NotBeNull(); + + var requirement = policy!.Requirements + .Should() + .ContainSingle() + .Subject + .Should() + .BeOfType() + .Subject; + + requirement.Action.Should().Be("users.delete"); + } + + [Fact] + public async Task GetPolicyAsync_ShouldPreservePolicyNameAsAction() + { + var sut = Create(); + + var policy = + await sut.GetPolicyAsync( + "users.security.sessions.revoke"); + + var requirement = policy!.Requirements + .OfType() + .Single(); + + requirement.Action.Should() + .Be("users.security.sessions.revoke"); + } + + [Fact] + public async Task GetDefaultPolicyAsync_ShouldUseConfiguredDefaultPolicy() + { + var configured = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build(); + + var options = new AuthorizationOptions + { + DefaultPolicy = configured + }; + + var sut = new UAuthPolicyProvider( + Options.Create(options)); + + var result = await sut.GetDefaultPolicyAsync(); + + result.Should().BeSameAs(configured); + } + + [Fact] + public async Task GetFallbackPolicyAsync_ShouldUseConfiguredFallbackPolicy() + { + var configured = new AuthorizationPolicyBuilder() + .RequireClaim("custom") + .Build(); + + var options = new AuthorizationOptions + { + FallbackPolicy = configured + }; + + var sut = new UAuthPolicyProvider( + Options.Create(options)); + + var result = await sut.GetFallbackPolicyAsync(); + + result.Should().BeSameAs(configured); + } + + private static UAuthPolicyProvider Create() + { + return new UAuthPolicyProvider( + Options.Create(new AuthorizationOptions())); + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs new file mode 100644 index 00000000..17f2f0b2 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs @@ -0,0 +1,432 @@ +using System.Security.Claims; +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.Policies.Abstractions; +using CodeBeam.UltimateAuth.Server.Authorization; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server; + +public sealed class UAuthResourceAccessOrchestratorTests +{ + private readonly Mock _authority = new(); + private readonly Mock _policyProvider = new(); + + private readonly DefaultHttpContext _httpContext; + private readonly UAuthResourceAccessOrchestrator _sut; + + public UAuthResourceAccessOrchestratorTests() + { + _httpContext = new DefaultHttpContext(); + + var accessor = new HttpContextAccessor + { + HttpContext = _httpContext + }; + + _sut = new UAuthResourceAccessOrchestrator( + _authority.Object, + _policyProvider.Object, + accessor); + } + + [Fact] + public async Task ExecuteAsync_WhenAllowed_ShouldExecuteCommandExactlyOnce() + { + var context = CreateContext(); + var policies = Array.Empty(); + + _policyProvider + .Setup(x => x.GetPolicies(It.IsAny())) + .Returns(policies); + + _authority + .Setup(x => x.Decide( + It.IsAny(), + policies)) + .Returns(AccessDecision.Allow()); + + var executions = 0; + + var command = new AccessCommand(_ => + { + executions++; + return Task.CompletedTask; + }); + + await _sut.ExecuteAsync(context, command); + + executions.Should().Be(1); + } + + [Fact] + public async Task ExecuteAsync_WhenDenied_ShouldThrowAuthorizationException() + { + var context = CreateContext(); + var policies = Array.Empty(); + + _policyProvider + .Setup(x => x.GetPolicies(It.IsAny())) + .Returns(policies); + + _authority + .Setup(x => x.Decide( + It.IsAny(), + policies)) + .Returns(AccessDecision.Deny("permission_required")); + + var command = new AccessCommand(_ => Task.CompletedTask); + + var act = () => _sut.ExecuteAsync(context, command); + + await act.Should() + .ThrowAsync() + .WithMessage("*permission_required*"); + } + + [Fact] + public async Task ExecuteAsync_WhenDenied_ShouldNeverExecuteCommand() + { + var context = CreateContext(); + var policies = Array.Empty(); + + _policyProvider + .Setup(x => x.GetPolicies(It.IsAny())) + .Returns(policies); + + _authority + .Setup(x => x.Decide( + It.IsAny(), + policies)) + .Returns(AccessDecision.Deny("denied")); + + var executed = false; + + var command = new AccessCommand(_ => + { + executed = true; + return Task.CompletedTask; + }); + + var act = () => _sut.ExecuteAsync(context, command); + + await act.Should() + .ThrowAsync(); + + executed.Should().BeFalse(); + } + + [Fact] + public async Task ExecuteAsync_WhenDeniedWithoutReason_ShouldUseDefaultReason() + { + var context = CreateContext(); + var policies = Array.Empty(); + + _policyProvider + .Setup(x => x.GetPolicies(It.IsAny())) + .Returns(policies); + + _authority + .Setup(x => x.Decide( + It.IsAny(), + policies)) + .Returns(AccessDecision.Deny(null)); + + var command = new AccessCommand(_ => Task.CompletedTask); + + var act = () => _sut.ExecuteAsync(context, command); + + await act.Should() + .ThrowAsync() + .WithMessage("*authorization_denied*"); + } + + [Fact] + public async Task ExecuteAsync_WhenReauthenticationRequired_ShouldNotExecuteCommand() + { + var context = CreateContext(); + var policies = Array.Empty(); + + _policyProvider + .Setup(x => x.GetPolicies(It.IsAny())) + .Returns(policies); + + _authority + .Setup(x => x.Decide( + It.IsAny(), + policies)) + .Returns(AccessDecision.ReauthenticationRequired()); + + var executed = false; + + var command = new AccessCommand(_ => + { + executed = true; + return Task.CompletedTask; + }); + + var act = () => _sut.ExecuteAsync(context, command); + + await act.Should() + .ThrowAsync() + .WithMessage("*reauthentication*"); + + executed.Should().BeFalse(); + } + + [Fact] + public async Task ExecuteAsync_ShouldPassEnrichedContextToPolicyProvider() + { + _httpContext.User = CreatePrincipal( + ("uauth:permission", "users.read"), + ("uauth:permission", "users.write")); + + var original = CreateContext(); + + AccessContext? captured = null; + + _policyProvider + .Setup(x => x.GetPolicies(It.IsAny())) + .Callback(x => captured = x) + .Returns(Array.Empty()); + + _authority + .Setup(x => x.Decide( + It.IsAny(), + It.IsAny>())) + .Returns(AccessDecision.Allow()); + + await _sut.ExecuteAsync( + original, + new AccessCommand(_ => Task.CompletedTask)); + + captured.Should().NotBeNull(); + + var compiled = GetCompiledPermissions(captured!); + + compiled.IsAllowed("users.read").Should().BeTrue(); + compiled.IsAllowed("users.write").Should().BeTrue(); + compiled.IsAllowed("users.delete").Should().BeFalse(); + } + + [Fact] + public async Task ExecuteAsync_ShouldCompileWildcardPermissionClaims() + { + _httpContext.User = CreatePrincipal( + ("uauth:permission", "users.*")); + + AccessContext? captured = null; + + _policyProvider + .Setup(x => x.GetPolicies(It.IsAny())) + .Callback(x => captured = x) + .Returns(Array.Empty()); + + _authority + .Setup(x => x.Decide( + It.IsAny(), + It.IsAny>())) + .Returns(AccessDecision.Allow()); + + await _sut.ExecuteAsync( + CreateContext(), + new AccessCommand(_ => Task.CompletedTask)); + + var compiled = GetCompiledPermissions(captured!); + + compiled.IsAllowed("users.read").Should().BeTrue(); + compiled.IsAllowed("users.delete").Should().BeTrue(); + compiled.IsAllowed("roles.read").Should().BeFalse(); + } + + [Fact] + public async Task ExecuteAsync_WithNoPermissionClaims_ShouldProvideEmptyPermissionSet() + { + _httpContext.User = CreatePrincipal( + (ClaimTypes.Name, "test-user")); + + AccessContext? captured = null; + + _policyProvider + .Setup(x => x.GetPolicies(It.IsAny())) + .Callback(x => captured = x) + .Returns(Array.Empty()); + + _authority + .Setup(x => x.Decide( + It.IsAny(), + It.IsAny>())) + .Returns(AccessDecision.Allow()); + + await _sut.ExecuteAsync( + CreateContext(), + new AccessCommand(_ => Task.CompletedTask)); + + var compiled = GetCompiledPermissions(captured!); + + compiled.IsAllowed("users.read").Should().BeFalse(); + compiled.IsAllowed("*").Should().BeFalse(); + } + + [Fact] + public async Task ExecuteAsync_ShouldPassEnrichedContextToAuthority() + { + _httpContext.User = CreatePrincipal( + ("uauth:permission", "orders.read")); + + AccessContext? authorityContext = null; + + var policies = Array.Empty(); + + _policyProvider + .Setup(x => x.GetPolicies(It.IsAny())) + .Returns(policies); + + _authority + .Setup(x => x.Decide( + It.IsAny(), + policies)) + .Callback>( + (context, _) => authorityContext = context) + .Returns(AccessDecision.Allow()); + + await _sut.ExecuteAsync( + CreateContext(), + new AccessCommand(_ => Task.CompletedTask)); + + authorityContext.Should().NotBeNull(); + + var compiled = GetCompiledPermissions(authorityContext!); + + compiled.IsAllowed("orders.read").Should().BeTrue(); + } + + [Fact] + public async Task ExecuteAsyncOfT_WhenAllowed_ShouldReturnCommandResult() + { + var context = CreateContext(); + var policies = Array.Empty(); + + _policyProvider + .Setup(x => x.GetPolicies(It.IsAny())) + .Returns(policies); + + _authority + .Setup(x => x.Decide( + It.IsAny(), + policies)) + .Returns(AccessDecision.Allow()); + + var command = new AccessCommand( + _ => Task.FromResult("result")); + + var result = await _sut.ExecuteAsync(context, command); + + result.Should().Be("result"); + } + + [Fact] + public async Task ExecuteAsyncOfT_WhenDenied_ShouldNeverExecuteCommand() + { + var context = CreateContext(); + var policies = Array.Empty(); + + _policyProvider + .Setup(x => x.GetPolicies(It.IsAny())) + .Returns(policies); + + _authority + .Setup(x => x.Decide( + It.IsAny(), + policies)) + .Returns(AccessDecision.Deny("forbidden")); + + var executed = false; + + var command = new AccessCommand(_ => + { + executed = true; + return Task.FromResult("result"); + }); + + var act = () => _sut.ExecuteAsync(context, command); + + await act.Should() + .ThrowAsync(); + + executed.Should().BeFalse(); + } + + [Fact] + public async Task ExecuteAsync_WhenCancellationRequested_ShouldNotEvaluateAuthorization() + { + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + var command = new AccessCommand(_ => Task.CompletedTask); + + var act = () => + _sut.ExecuteAsync( + CreateContext(), + command, + cts.Token); + + await act.Should() + .ThrowAsync(); + + _policyProvider.Verify( + x => x.GetPolicies(It.IsAny()), + Times.Never); + + _authority.Verify( + x => x.Decide( + It.IsAny(), + It.IsAny>()), + Times.Never); + } + + private static ClaimsPrincipal CreatePrincipal( + params (string Type, string Value)[] claims) + { + return new ClaimsPrincipal( + new ClaimsIdentity( + claims.Select(x => new Claim(x.Type, x.Value)), + authenticationType: "test")); + } + + private static AccessContext CreateContext() + { + return new AccessContext( + actorUserKey: null, + actorTenant: default, + isAuthenticated: true, + isSystemActor: false, + actorChainId: null, + resource: "users", + targetUserKey: null, + resourceTenant: default, + action: "users.read", + attributes: EmptyAttributes.Instance); + } + + private static CompiledPermissionSet GetCompiledPermissions( + AccessContext context) + { + context.Attributes + .TryGetValue( + UAuthConstants.Access.Permissions, + out var value) + .Should() + .BeTrue(); + + return value.Should() + .BeOfType() + .Subject; + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAuthenticationHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAuthenticationHandlerTests.cs new file mode 100644 index 00000000..f3c1f233 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAuthenticationHandlerTests.cs @@ -0,0 +1,470 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server; +using CodeBeam.UltimateAuth.Server.Authentication; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using FluentAssertions; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server.Authentication; + +public sealed class UAuthResourceAuthenticationHandlerTests +{ + private static readonly TenantKey Tenant = TenantKey.FromExternal("tenant-1"); + + private static readonly UserKey User = UserKey.FromString("user-1"); + + private static readonly DateTimeOffset Now = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task AuthenticateAsync_WhenCredentialDoesNotExist_ShouldReturnNoResult() + { + var setup = CreateTestContext(); + + setup.CredentialResolver + .Setup(x => x.ResolveAsync( + It.IsAny())) + .ReturnsAsync((TransportCredential?)null); + + var result = await setup.HttpContext.AuthenticateAsync( + UAuthConstants.SchemeDefaults.GlobalScheme); + + result.None.Should().BeTrue(); + + setup.SessionValidator.Verify( + x => x.ValidateSessionAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task AuthenticateAsync_WhenCredentialIsMalformed_ShouldFail() + { + var setup = CreateTestContext(); + + setup.CredentialResolver + .Setup(x => x.ResolveAsync(It.IsAny())) + .ReturnsAsync(new TransportCredential + { + Kind = TransportCredentialKind.Session, + Value = "invalid", + Device = CreateDeviceInfo() + }); + + var result = await setup.HttpContext.AuthenticateAsync( + UAuthConstants.SchemeDefaults.GlobalScheme); + + result.Succeeded.Should().BeFalse(); + result.Failure.Should().NotBeNull(); + result.Failure!.Message.Should().Be("Invalid session"); + + setup.SessionValidator.Verify( + x => x.ValidateSessionAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task AuthenticateAsync_WhenSessionIsInvalid_ShouldReturnNoResult() + { + var setup = CreateTestContext(); + + setup.SessionValidator + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(CreateInvalidValidationResult()); + + var result = await setup.HttpContext.AuthenticateAsync( + UAuthConstants.SchemeDefaults.GlobalScheme); + + result.None.Should().BeTrue(); + result.Succeeded.Should().BeFalse(); + } + + [Fact] + public async Task AuthenticateAsync_WhenUserKeyIsMissing_ShouldReturnNoResult() + { + var setup = CreateTestContext(); + + var validationResult = SessionValidationResult.Active( + tenant: Tenant, + userKey: null, + sessionId: SessionId, + chainId: ChainId, + rootId: RootId, + claims: ClaimsSnapshot.Empty, + authenticatedAt: Now); + + setup.SessionValidator + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(validationResult); + + var result = await setup.HttpContext.AuthenticateAsync( + UAuthConstants.SchemeDefaults.GlobalScheme); + + result.None.Should().BeTrue(); + result.Succeeded.Should().BeFalse(); + } + + [Fact] + public async Task AuthenticateAsync_WhenSessionIsValid_ShouldAuthenticateUser() + { + var setup = CreateTestContext(); + + setup.SessionValidator + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(CreateValidValidationResult()); + + var result = await setup.HttpContext.AuthenticateAsync( + UAuthConstants.SchemeDefaults.GlobalScheme); + + result.Succeeded.Should().BeTrue(); + result.Principal.Should().NotBeNull(); + result.Ticket.Should().NotBeNull(); + + result.Ticket!.AuthenticationScheme.Should() + .Be(UAuthConstants.SchemeDefaults.GlobalScheme); + + result.Principal!.Identity!.IsAuthenticated.Should().BeTrue(); + + result.Principal.Identity.AuthenticationType.Should() + .Be(UAuthConstants.SchemeDefaults.GlobalScheme); + } + + [Fact] + public async Task AuthenticateAsync_WhenSessionIsValid_ShouldAddNameIdentifierClaim() + { + var setup = CreateTestContext(); + + setup.SessionValidator + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(CreateValidValidationResult()); + + var result = await setup.HttpContext.AuthenticateAsync( + UAuthConstants.SchemeDefaults.GlobalScheme); + + result.Principal! + .FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)! + .Value + .Should() + .Be(User.Value); + } + + [Fact] + public async Task AuthenticateAsync_WhenSessionContainsClaims_ShouldCopyClaimsToPrincipal() + { + var setup = CreateTestContext(); + + var validationResult = CreateValidValidationResult( + claims: CreateClaims( + ("uauth:permission", "users.read"), + ("uauth:permission", "users.write"), + ("uauth:permission", "sessions.revoke"), + ("uauth:role", "admin"))); + + setup.SessionValidator + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(validationResult); + + var result = await setup.HttpContext.AuthenticateAsync( + UAuthConstants.SchemeDefaults.GlobalScheme); + + result.Succeeded.Should().BeTrue(); + + result.Principal! + .FindAll("uauth:permission") + .Select(x => x.Value) + .Should() + .BeEquivalentTo( + "users.read", + "users.write", + "sessions.revoke"); + + result.Principal + .FindAll("uauth:role") + .Select(x => x.Value) + .Should() + .ContainSingle() + .Which + .Should() + .Be("admin"); + } + + [Fact] + public async Task AuthenticateAsync_ShouldPassResolvedTenantToSessionValidator() + { + SessionValidationContext? captured = null; + + var setup = CreateTestContext(); + + setup.SessionValidator + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (context, _) => captured = context) + .ReturnsAsync(CreateValidValidationResult()); + + await setup.HttpContext.AuthenticateAsync( + UAuthConstants.SchemeDefaults.GlobalScheme); + + captured.Should().NotBeNull(); + captured!.Tenant.Should().Be(Tenant); + } + + [Fact] + public async Task AuthenticateAsync_ShouldUseClockForValidationTime() + { + SessionValidationContext? captured = null; + + var setup = CreateTestContext(); + + setup.SessionValidator + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (context, _) => captured = context) + .ReturnsAsync(CreateValidValidationResult()); + + await setup.HttpContext.AuthenticateAsync( + UAuthConstants.SchemeDefaults.GlobalScheme); + + captured.Should().NotBeNull(); + captured!.Now.Should().Be(Now); + } + + [Fact] + public async Task AuthenticateAsync_ShouldCreateDeviceFromTransportCredential() + { + var setup = CreateTestContext(); + + var deviceInfo = CreateDeviceInfo(); + + var transportCredential = new TransportCredential + { + Kind = TransportCredentialKind.Session, + Value = SessionId, + Device = deviceInfo + }; + + var expectedDevice = DeviceContext.Create( + deviceInfo.DeviceId, + deviceType: "desktop", + platform: "desktop", + operatingSystem: "windows 11", + browser: "test-browser", + ipAddress: "127.0.0.1"); + + setup.CredentialResolver + .Setup(x => x.ResolveAsync(It.IsAny())) + .ReturnsAsync(transportCredential); + + setup.DeviceFactory + .Setup(x => x.Create(deviceInfo)) + .Returns(expectedDevice); + + SessionValidationContext? captured = null; + + setup.SessionValidator + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (context, _) => captured = context) + .ReturnsAsync(CreateValidValidationResult()); + + var result = await setup.HttpContext.AuthenticateAsync( + UAuthConstants.SchemeDefaults.GlobalScheme); + + result.Succeeded.Should().BeTrue(); + + setup.DeviceFactory.Verify( + x => x.Create(deviceInfo), + Times.Once); + + captured.Should().NotBeNull(); + + captured!.Device.Should().BeSameAs(expectedDevice); + } + + [Fact] + public async Task AuthenticateAsync_ShouldPassSessionIdFromTransportCredential() + { + SessionValidationContext? captured = null; + + var setup = CreateTestContext(); + + setup.SessionValidator + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (context, _) => captured = context) + .ReturnsAsync(CreateValidValidationResult()); + + await setup.HttpContext.AuthenticateAsync( + UAuthConstants.SchemeDefaults.GlobalScheme); + + captured.Should().NotBeNull(); + captured!.SessionId.Should().Be(SessionId); + } + + private static TestSetup CreateTestContext( + Mock? credentialResolver = null, + Mock? deviceFactory = null) + { + credentialResolver ??= + new Mock(); + + deviceFactory ??= + new Mock(); + + var sessionValidator = + new Mock(); + + var clock = + new Mock(); + + clock + .SetupGet(x => x.UtcNow) + .Returns(Now); + + credentialResolver + .Setup(x => x.ResolveAsync( + It.IsAny())) + .ReturnsAsync(CreateCredential(TestSessionId)); + + deviceFactory + .Setup(x => x.Create( + It.IsAny())) + .Returns(CreateDeviceContext()); + + var services = new ServiceCollection(); + + services.AddLogging(); + + services.AddSingleton( + credentialResolver.Object); + + services.AddSingleton( + sessionValidator.Object); + + services.AddSingleton( + deviceFactory.Object); + + services.AddSingleton( + clock.Object); + + services + .AddAuthentication( + UAuthConstants.SchemeDefaults.GlobalScheme) + .AddUAuthResourceApi(); + + var provider = services.BuildServiceProvider(); + + var http = new DefaultHttpContext + { + RequestServices = provider + }; + + http.Items[ + UAuthConstants.HttpItems.TenantContextKey] = + UAuthTenantContext.Resolved(Tenant); + + return new TestSetup( + http, + credentialResolver, + sessionValidator, + deviceFactory, + clock); + } + + private static TransportCredential CreateCredential(string value, DeviceInfo? device = null) + { + return new TransportCredential + { + Kind = TransportCredentialKind.Session, + Value = value, + Device = device ?? CreateDeviceInfo() + }; + } + + private static DeviceInfo CreateDeviceInfo() + { + return new DeviceInfo + { + DeviceId = DeviceId, + DeviceType = "desktop", + OperatingSystem = "Windows 11", + Platform = "desktop", + Browser = "test-browser", + IpAddress = "127.0.0.1" + }; + } + + private static DeviceContext CreateDeviceContext() + { + return DeviceContext.Anonymous(); + } + + private static SessionValidationResult CreateInvalidValidationResult() + { + return SessionValidationResult.Invalid( + SessionState.Revoked); + } + + private static SessionValidationResult CreateValidValidationResult( + ClaimsSnapshot? claims = null, + UserKey? userKey = null) + { + return SessionValidationResult.Active( + tenant: Tenant, + userKey: userKey ?? User, + sessionId: SessionId, + chainId: ChainId, + rootId: RootId, + claims: claims ?? ClaimsSnapshot.Empty, + authenticatedAt: Now); + } + + private static ClaimsSnapshot CreateClaims(params (string Type, string Value)[] claims) + { + return ClaimsSnapshot.From(claims); + } + + private const string TestSessionId = "test-session-id-00000000000000000000000000000001"; + + private static AuthSessionId SessionId => AuthSessionId.Parse(TestSessionId, null); + + private static SessionChainId ChainId => SessionChainId.From(Guid.Parse("11111111-1111-1111-1111-111111111111")); + + private static SessionRootId RootId => SessionRootId.From(Guid.Parse("22222222-2222-2222-2222-222222222222")); + + private static DeviceId DeviceId => DeviceId.Create("test-device-123456789123456789123456789123456789"); + + private sealed record TestSetup( + DefaultHttpContext HttpContext, + Mock CredentialResolver, + Mock SessionValidator, + Mock DeviceFactory, + Mock Clock); +} \ No newline at end of file From a68f516852cc83f21dce9fe2051f3ff47324cf36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Tue, 6 Oct 2026 15:18:56 +0300 Subject: [PATCH 12/16] ResourceApi Tests --- .../IUAuthMultiTenantOptionsAccessor.cs | 8 + .../Contracts/SessionRefreshResult.cs | 16 - .../Extensions/AuthFailureReasonExtensions.cs | 20 - .../Extensions/ServiceCollectionExtensions.cs | 3 + .../ServerMultiTenantOptionsAccessor.cs | 18 + .../SessionValidationMiddleware.cs | 2 +- .../Middlewares/TenantMiddleware.cs | 12 +- .../MultiTenancy/UAuthTenantResolver.cs | 9 +- .../ResourceApi/RemoteSessionValidator.cs | 1 + .../ResourceApiMultiTenantOptionsAccessor.cs | 18 + ...Beam.UltimateAuth.Tests.Integration.csproj | 1 + .../ResourceApiAuthenticationTests.cs | 265 +++++++++++ .../ResourceApiAuthorizationTests.cs | 422 ++++++++++++++++++ .../ResourceApiTenantIsolationTests.cs | 264 +++++++++++ .../ResourceApi/ResourceApiTestHost.cs | 62 +++ .../ResourceApi/TestResourceController.cs | 66 +++ .../UAuthResourceApiRegistrationTests.cs | 346 ++++++++++++++ 17 files changed, 1485 insertions(+), 48 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Tenant/IUAuthMultiTenantOptionsAccessor.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Contracts/SessionRefreshResult.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/AuthFailureReasonExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/ServerMultiTenantOptionsAccessor.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceApiMultiTenantOptionsAccessor.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthenticationTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthorizationTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiTenantIsolationTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiTestHost.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/TestResourceController.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/ResourceApi/UAuthResourceApiRegistrationTests.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Tenant/IUAuthMultiTenantOptionsAccessor.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Tenant/IUAuthMultiTenantOptionsAccessor.cs new file mode 100644 index 00000000..8bb43da9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Tenant/IUAuthMultiTenantOptionsAccessor.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IUAuthMultiTenantOptionsAccessor +{ + UAuthMultiTenantOptions MultiTenant { get; } +} \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/SessionRefreshResult.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/SessionRefreshResult.cs deleted file mode 100644 index e0d6fc42..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Contracts/SessionRefreshResult.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Contracts; - -public sealed class SessionRefreshResult -{ - public bool Succeeded { get; } - public string? NewSessionId { get; } - - private SessionRefreshResult(bool succeeded, string? newSessionId) - { - Succeeded = succeeded; - NewSessionId = newSessionId; - } - - public static SessionRefreshResult Success(string? newSessionId = null) => new(true, newSessionId); - public static SessionRefreshResult Failed() => new(false, null); -} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFailureReasonExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFailureReasonExtensions.cs deleted file mode 100644 index d4ae11c5..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFailureReasonExtensions.cs +++ /dev/null @@ -1,20 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Server.Extensions; - -public static class AuthFailureReasonExtensions -{ - public static string ToDefaultCode(this AuthFailureReason reason) - => reason switch - { - AuthFailureReason.InvalidCredentials => "invalid_credentials", - AuthFailureReason.LockedOut => "locked", - AuthFailureReason.RequiresMfa => "mfa_required", - AuthFailureReason.SessionExpired => "session_expired", - AuthFailureReason.SessionRevoked => "session_revoked", - AuthFailureReason.TenantDisabled => "tenant_disabled", - AuthFailureReason.Unauthorized => "unauthorized", - AuthFailureReason.ReauthenticationRequired => "reauthentication_required", - _ => "failed" - }; -} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index 6eb0f02d..690e5e98 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -133,6 +133,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.AddSingleton, UAuthServerMultiTenantOptionsValidator>(); services.AddSingleton, UAuthServerUserIdentifierOptionsValidator>(); services.AddSingleton, UAuthServerSessionResolutionOptionsValidator>(); + services.AddScoped(); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); @@ -461,6 +462,8 @@ private static IServiceCollection AddUltimateAuthResourceInternal(this IServiceC }; }); + services.AddScoped(); + // ASP.NET Core Integration services.AddHttpContextAccessor(); services.AddAuthentication(options => diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/ServerMultiTenantOptionsAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/ServerMultiTenantOptionsAccessor.cs new file mode 100644 index 00000000..351ef1a8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/ServerMultiTenantOptionsAccessor.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class ServerMultiTenantOptionsAccessor : IUAuthMultiTenantOptionsAccessor +{ + private readonly IOptions _options; + + public ServerMultiTenantOptionsAccessor(IOptions options) + { + _options = options; + } + + public UAuthMultiTenantOptions MultiTenant => _options.Value.MultiTenant; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionValidationMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionValidationMiddleware.cs index 45365641..17cf6471 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionValidationMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionValidationMiddleware.cs @@ -47,7 +47,7 @@ public async Task Invoke(HttpContext context) Device = device }); - context.Items["__UAuth.SessionValidationResult"] = result; + context.Items[UAuthConstants.HttpItems.SessionValidationResult] = result; await _next(context); } diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs index 7a22310c..bf907c26 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs @@ -1,10 +1,8 @@ -using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.MultiTenancy; -using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Server.Middlewares; @@ -17,12 +15,12 @@ public TenantMiddleware(RequestDelegate next) _next = next; } - public async Task InvokeAsync(HttpContext context, ITenantResolver resolver, IOptions options) + public async Task InvokeAsync(HttpContext context, ITenantResolver resolver, IUAuthMultiTenantOptionsAccessor options) { - var opts = options.Value; + var opts = options.MultiTenant; TenantResolutionResult resolution; - if (!opts.MultiTenant.Enabled) + if (!opts.Enabled) { context.Items[UAuthConstants.HttpItems.TenantContextKey] = UAuthTenantContext.SingleTenant(); await _next(context); diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs index 440942ec..934d33a0 100644 --- a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; @@ -9,12 +10,12 @@ namespace CodeBeam.UltimateAuth.Server.MultiTenancy; public sealed class UAuthTenantResolver : ITenantResolver { private readonly ITenantIdResolver _idResolver; - private readonly UAuthServerOptions _options; + private readonly IUAuthMultiTenantOptionsAccessor _options; - public UAuthTenantResolver(ITenantIdResolver idResolver, IOptions options) + public UAuthTenantResolver(ITenantIdResolver idResolver, IUAuthMultiTenantOptionsAccessor options) { _idResolver = idResolver; - _options = options.Value; + _options = options; } public async Task ResolveAsync(HttpContext context) diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs index f5bab9db..092fbba4 100644 --- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs @@ -6,6 +6,7 @@ namespace CodeBeam.UltimateAuth.Server.ResourceApi; +// TODO: Resource API calls make two calls to here. Investigate. internal sealed class RemoteSessionValidator : ISessionValidator { private readonly HttpClient _http; diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceApiMultiTenantOptionsAccessor.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceApiMultiTenantOptionsAccessor.cs new file mode 100644 index 00000000..00a3b4c0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceApiMultiTenantOptionsAccessor.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.ResourceApi; + +internal sealed class ResourceApiMultiTenantOptionsAccessor : IUAuthMultiTenantOptionsAccessor +{ + private readonly IOptions _options; + + public ResourceApiMultiTenantOptionsAccessor(IOptions options) + { + _options = options; + } + + public UAuthMultiTenantOptions MultiTenant => _options.Value.MultiTenant; +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/CodeBeam.UltimateAuth.Tests.Integration.csproj b/tests/CodeBeam.UltimateAuth.Tests.Integration/CodeBeam.UltimateAuth.Tests.Integration.csproj index 3871c097..db10f6e6 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/CodeBeam.UltimateAuth.Tests.Integration.csproj +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/CodeBeam.UltimateAuth.Tests.Integration.csproj @@ -14,6 +14,7 @@ + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthenticationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthenticationTests.cs new file mode 100644 index 00000000..05cbb8d9 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthenticationTests.cs @@ -0,0 +1,265 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server; +using FluentAssertions; +using Moq; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Timers; + +namespace CodeBeam.UltimateAuth.Tests.Integration.ResourceApi; + +public sealed class ResourceApiAuthenticationTests +{ + private const string SessionValue = "test-resource-session-000000000000000000000001"; + private static readonly TenantKey Tenant = TenantKey.FromExternal("tenant-1"); + private static readonly UserKey User = UserKey.FromString("user-1"); + private static readonly DateTimeOffset Now = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); + private static AuthSessionId SessionId => AuthSessionId.Parse(SessionValue, null); + private static SessionChainId ChainId => SessionChainId.From(Guid.Parse("11111111-1111-1111-1111-111111111111")); + private static SessionRootId RootId => SessionRootId.From(Guid.Parse("22222222-2222-2222-2222-222222222222")); + + // -------------------------------------------------- + // Anonymous + // -------------------------------------------------- + + [Fact] + public async Task AnonymousEndpoint_WithoutCredential_ShouldReturnOk() + { + var validator = new Mock(); + + await using var host = + await ResourceApiTestHost.CreateAsync( + validator.Object); + + var response = await host.Client.GetAsync( + "/__tests/resource/anonymous"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + validator.Verify( + x => x.ValidateSessionAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + // -------------------------------------------------- + // 401 + // -------------------------------------------------- + + [Fact] + public async Task ProtectedEndpoint_WithoutCredential_ShouldReturnUnauthorized() + { + var validator = new Mock(); + + await using var host = + await ResourceApiTestHost.CreateAsync( + validator.Object); + + var response = await host.Client.GetAsync( + "/__tests/resource/authenticated"); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + validator.Verify( + x => x.ValidateSessionAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + // -------------------------------------------------- + // Invalid session -> 401 + // -------------------------------------------------- + + [Fact] + public async Task ProtectedEndpoint_WithInvalidSession_ShouldReturnUnauthorized() + { + var validator = new Mock(); + + validator + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync( + SessionValidationResult.Invalid( + SessionState.Revoked)); + + await using var host = + await ResourceApiTestHost.CreateAsync( + validator.Object); + + AddSession(host.Client); + + var response = await host.Client.GetAsync( + "/__tests/resource/authenticated"); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + validator.Verify( + x => x.ValidateSessionAsync( + It.IsAny(), + It.IsAny()), + Times.AtLeastOnce); + } + + // -------------------------------------------------- + // Valid session -> 200 + // -------------------------------------------------- + + [Fact] + public async Task ProtectedEndpoint_WithValidSession_ShouldReturnOk() + { + var validator = CreateValidator( + ClaimsSnapshot.Empty); + + await using var host = + await ResourceApiTestHost.CreateAsync( + validator.Object); + + AddSession(host.Client); + + var response = await host.Client.GetAsync( + "/__tests/resource/authenticated"); + + response.StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + // -------------------------------------------------- + // Valid session but missing role -> 403 + // -------------------------------------------------- + + [Fact] + public async Task AdminEndpoint_WithAuthenticatedNonAdminUser_ShouldReturnForbidden() + { + var validator = CreateValidator( + ClaimsSnapshot.From( + (ClaimTypes.Role, "User"))); + + await using var host = + await ResourceApiTestHost.CreateAsync( + validator.Object); + + AddSession(host.Client); + + var response = await host.Client.GetAsync( + "/__tests/resource/admin"); + + response.StatusCode.Should() + .Be(HttpStatusCode.Forbidden); + } + + // -------------------------------------------------- + // Valid Admin -> 200 + // -------------------------------------------------- + + [Fact] + public async Task AdminEndpoint_WithAdminRole_ShouldReturnOk() + { + var validator = CreateValidator( + ClaimsSnapshot.From( + (ClaimTypes.Role, "Admin"))); + + await using var host = + await ResourceApiTestHost.CreateAsync( + validator.Object); + + AddSession(host.Client); + + var response = await host.Client.GetAsync( + "/__tests/resource/admin"); + + response.StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + // -------------------------------------------------- + // Claims really reach developer's controller + // -------------------------------------------------- + + [Fact] + public async Task AuthenticatedEndpoint_ShouldExposeUltimateAuthIdentityToController() + { + var validator = CreateValidator( + ClaimsSnapshot.From( + (ClaimTypes.Role, "Admin"), + ("uauth:permission", "products.read.self"), + ("uauth:permission", "products.update.admin"))); + + await using var host = + await ResourceApiTestHost.CreateAsync( + validator.Object); + + AddSession(host.Client); + + var response = await host.Client.GetAsync( + "/__tests/resource/identity"); + + response.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var body = + await response.Content + .ReadFromJsonAsync(); + + body.Should().NotBeNull(); + + body!.UserId.Should().Be(User.Value); + + body.Roles.Should() + .ContainSingle() + .Which.Should() + .Be("Admin"); + + body.Permissions.Should() + .BeEquivalentTo( + "products.read.self", + "products.update.admin"); + } + + private static Mock CreateValidator( + ClaimsSnapshot claims) + { + var validator = new Mock(); + + validator + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync( + SessionValidationResult.Active( + tenant: Tenant, + userKey: User, + sessionId: SessionId, + chainId: ChainId, + rootId: RootId, + claims: claims, + authenticatedAt: Now)); + + return validator; + } + + private static void AddSession(HttpClient client) + { + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue( + "Bearer", + SessionValue); + } + + private sealed class IdentityResponse + { + public string? UserId { get; init; } + + public string[] Roles { get; init; } = []; + + public string[] Permissions { get; init; } = []; + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthorizationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthorizationTests.cs new file mode 100644 index 00000000..271e73a1 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthorizationTests.cs @@ -0,0 +1,422 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Policies.Abstractions; +using CodeBeam.UltimateAuth.Server; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using System.Net; +using System.Net.Http.Headers; + +namespace CodeBeam.UltimateAuth.Tests.Integration.ResourceApi; + +public sealed class ResourceApiAuthorizationTests +{ + private const string SessionValue = + "test-resource-session-000000000000000000000001"; + + private static readonly TenantKey Tenant = + TenantKey.FromExternal("tenant-1"); + + private static readonly UserKey User = + UserKey.FromString("user-1"); + + private static readonly DateTimeOffset Now = + new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); + + private static AuthSessionId SessionId => + AuthSessionId.Parse(SessionValue, null); + + private static SessionChainId ChainId => + SessionChainId.From( + Guid.Parse("11111111-1111-1111-1111-111111111111")); + + private static SessionRootId RootId => + SessionRootId.From( + Guid.Parse("22222222-2222-2222-2222-222222222222")); + + [Fact] + public async Task PolicyEndpoint_WithoutCredential_ShouldReturnUnauthorized() + { + var validator = new Mock(); + + await using var host = + await ResourceApiTestHost.CreateAsync(validator.Object); + + var response = await host.Client.GetAsync( + "/__tests/resource/products/read"); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task PolicyEndpoint_WithInvalidSession_ShouldReturnUnauthorized() + { + var validator = new Mock(); + + validator + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync( + SessionValidationResult.Invalid( + SessionState.Revoked)); + + await using var host = + await ResourceApiTestHost.CreateAsync(validator.Object); + + AddSession(host.Client); + + var response = await host.Client.GetAsync( + "/__tests/resource/products/read"); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task PolicyEndpoint_WhenAuthorityAllows_ShouldReturnOk() + { + var validator = CreateValidValidator( + ("uauth:permission", TestResourceController.ProductsRead)); + + var authority = CreateAllowAuthority(); + var policyProvider = CreatePolicyProvider(); + + await using var host = + await CreateHostAsync( + validator.Object, + authority.Object, + policyProvider.Object); + + AddSession(host.Client); + + var response = await host.Client.GetAsync( + "/__tests/resource/products/read"); + + response.StatusCode.Should() + .Be(HttpStatusCode.OK); + + authority.Verify( + x => x.Decide( + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task PolicyEndpoint_WhenAuthorityDenies_ShouldReturnForbidden() + { + var validator = CreateValidValidator(); + + var authority = CreateDenyAuthority(); + var policyProvider = CreatePolicyProvider(); + + await using var host = + await CreateHostAsync( + validator.Object, + authority.Object, + policyProvider.Object); + + AddSession(host.Client); + + var response = await host.Client.GetAsync( + "/__tests/resource/products/read"); + + response.StatusCode.Should() + .Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task ProductsReadPolicy_ShouldBuildExpectedAccessContext() + { + var validator = CreateValidValidator( + ("uauth:permission", TestResourceController.ProductsRead)); + + AccessContext? captured = null; + + var authority = new Mock(); + + authority + .Setup(x => x.Decide( + It.IsAny(), + It.IsAny>())) + .Callback>( + (context, _) => captured = context) + .Returns(CreateAllowedDecision()); + + var policyProvider = CreatePolicyProvider(); + + await using var host = + await CreateHostAsync( + validator.Object, + authority.Object, + policyProvider.Object); + + AddSession(host.Client); + + var response = await host.Client.GetAsync( + "/__tests/resource/products/read"); + + response.StatusCode.Should() + .Be(HttpStatusCode.OK); + + captured.Should().NotBeNull(); + + captured!.Action.Should() + .Be(TestResourceController.ProductsRead); + + captured.Resource.Should() + .Be("products"); + + captured.ActorUserKey.Should() + .Be(User); + + captured.ActorTenant.Should() + .Be(TenantKey.Single); + + captured.ResourceTenant.Should() + .Be(TenantKey.Single); + + captured.IsAuthenticated.Should() + .BeTrue(); + } + + [Fact] + public async Task PolicyAuthorization_ShouldEnrichAccessContextWithPermissionsFromClaims() + { + var validator = CreateValidValidator( + ("uauth:permission", TestResourceController.ProductsRead), + ("uauth:permission", "orders.read.self")); + + AccessContext? captured = null; + + var authority = new Mock(); + + authority + .Setup(x => x.Decide( + It.IsAny(), + It.IsAny>())) + .Callback>( + (context, _) => captured = context) + .Returns(CreateAllowedDecision()); + + var policyProvider = CreatePolicyProvider(); + + await using var host = + await CreateHostAsync( + validator.Object, + authority.Object, + policyProvider.Object); + + AddSession(host.Client); + + var response = await host.Client.GetAsync( + "/__tests/resource/products/read"); + + response.StatusCode.Should() + .Be(HttpStatusCode.OK); + + captured.Should().NotBeNull(); + + captured!.Attributes.Should() + .ContainKey(UAuthConstants.Access.Permissions); + + captured.Attributes[UAuthConstants.Access.Permissions] + .Should() + .BeOfType(); + } + + [Fact] + public async Task PolicyAuthorization_ShouldPassEnrichedContextToPolicyProvider() + { + var validator = CreateValidValidator( + ("uauth:permission", TestResourceController.ProductsRead)); + + AccessContext? policyContext = null; + + var policyProvider = new Mock(); + + policyProvider + .Setup(x => x.GetPolicies( + It.IsAny())) + .Callback( + context => policyContext = context) + .Returns(Array.Empty()); + + var authority = CreateAllowAuthority(); + + await using var host = + await CreateHostAsync( + validator.Object, + authority.Object, + policyProvider.Object); + + AddSession(host.Client); + + var response = await host.Client.GetAsync( + "/__tests/resource/products/read"); + + response.StatusCode.Should() + .Be(HttpStatusCode.OK); + + policyContext.Should().NotBeNull(); + + policyContext!.Attributes.Should() + .ContainKey(UAuthConstants.Access.Permissions); + + policyContext.Attributes[UAuthConstants.Access.Permissions] + .Should() + .BeOfType(); + } + + [Fact] + public async Task ProductsUpdatePolicy_ShouldPreserveFullActionAndResolveProductsResource() + { + var validator = CreateValidValidator( + ("uauth:permission", TestResourceController.ProductsUpdate)); + + AccessContext? captured = null; + + var authority = new Mock(); + + authority + .Setup(x => x.Decide( + It.IsAny(), + It.IsAny>())) + .Callback>( + (context, _) => captured = context) + .Returns(CreateAllowedDecision()); + + var policyProvider = CreatePolicyProvider(); + + await using var host = + await CreateHostAsync( + validator.Object, + authority.Object, + policyProvider.Object); + + AddSession(host.Client); + + using var request = new HttpRequestMessage( + HttpMethod.Put, + "/__tests/resource/products/42"); + + var response = await host.Client.SendAsync(request); + + response.StatusCode.Should() + .Be(HttpStatusCode.OK); + + captured.Should().NotBeNull(); + + captured!.Action.Should() + .Be(TestResourceController.ProductsUpdate); + + captured.Resource.Should() + .Be("products"); + } + + private static async Task CreateHostAsync( + ISessionValidator validator, + IAccessAuthority authority, + IAccessPolicyProvider policyProvider) + { + return await ResourceApiTestHost.CreateAsync( + validator, + services => + { + services.AddScoped( + _ => authority); + + services.AddScoped( + _ => policyProvider); + }); + } + + private static Mock CreatePolicyProvider() + { + var provider = new Mock(); + + provider + .Setup(x => x.GetPolicies( + It.IsAny())) + .Returns(Array.Empty()); + + return provider; + } + + private static Mock CreateAllowAuthority() + { + var authority = new Mock(); + + authority + .Setup(x => x.Decide( + It.IsAny(), + It.IsAny>())) + .Returns(CreateAllowedDecision()); + + return authority; + } + + private static Mock CreateDenyAuthority() + { + var authority = new Mock(); + + authority + .Setup(x => x.Decide( + It.IsAny(), + It.IsAny>())) + .Returns(CreateDeniedDecision()); + + return authority; + } + + private static Mock CreateValidValidator( + params (string Type, string Value)[] claims) + { + var validator = new Mock(); + + var snapshot = claims.Length == 0 + ? ClaimsSnapshot.Empty + : ClaimsSnapshot.From(claims); + + validator + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync( + SessionValidationResult.Active( + tenant: Tenant, + userKey: User, + sessionId: SessionId, + chainId: ChainId, + rootId: RootId, + claims: snapshot, + authenticatedAt: Now)); + + return validator; + } + + private static void AddSession(HttpClient client) + { + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue( + "Bearer", + SessionValue); + } + + private static AccessDecision CreateAllowedDecision() + { + return AccessDecision.Allow(); + } + + private static AccessDecision CreateDeniedDecision() + { + return AccessDecision.Deny("missing_permission"); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiTenantIsolationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiTenantIsolationTests.cs new file mode 100644 index 00000000..ef23ac53 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiTenantIsolationTests.cs @@ -0,0 +1,264 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server; +using FluentAssertions; +using Moq; +using System.Net; +using System.Net.Http.Headers; + +namespace CodeBeam.UltimateAuth.Tests.Integration.ResourceApi; + +public sealed class ResourceApiTenantIsolationTests +{ + private const string SessionValue = + "test-resource-session-000000000000000000000001"; + + private const string TenantHeader = "X-Tenant-Id"; + + private static readonly TenantKey TenantA = + TenantKey.FromExternal("tenant-a"); + + private static readonly TenantKey TenantB = + TenantKey.FromExternal("tenant-b"); + + private static readonly UserKey User = + UserKey.FromString("user-1"); + + private static readonly DateTimeOffset Now = + new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); + + private static AuthSessionId SessionId => + AuthSessionId.Parse(SessionValue, null); + + private static SessionChainId ChainId => + SessionChainId.From( + Guid.Parse("11111111-1111-1111-1111-111111111111")); + + private static SessionRootId RootId => + SessionRootId.From( + Guid.Parse("22222222-2222-2222-2222-222222222222")); + + [Fact] + public async Task RequestTenant_ShouldBePassedToSessionValidation() + { + SessionValidationContext? captured = null; + + var validator = new Mock(); + + validator + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (context, _) => captured = context) + .ReturnsAsync(CreateActiveResult(TenantA)); + + await using var host = + await CreateMultiTenantHostAsync(validator.Object); + + AddSession(host.Client); + AddTenant(host.Client, "tenant-a"); + + var response = await host.Client.GetAsync( + "/__tests/resource/authenticated"); + + response.StatusCode.Should() + .Be(HttpStatusCode.OK); + + captured.Should().NotBeNull(); + + captured!.Tenant.Should() + .Be(TenantA); + } + + [Fact] + public async Task TenantA_WithTenantASession_ShouldAuthenticate() + { + var validator = new Mock(); + + validator + .Setup(x => x.ValidateSessionAsync( + It.Is( + context => context.Tenant == TenantA), + It.IsAny())) + .ReturnsAsync(CreateActiveResult(TenantA)); + + await using var host = + await CreateMultiTenantHostAsync(validator.Object); + + AddSession(host.Client); + AddTenant(host.Client, "tenant-a"); + + var response = await host.Client.GetAsync( + "/__tests/resource/authenticated"); + + response.StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task TenantB_WithTenantASession_ShouldNotAuthenticate() + { + var validator = new Mock(); + + validator + .Setup(x => x.ValidateSessionAsync( + It.Is( + context => context.Tenant == TenantB), + It.IsAny())) + .ReturnsAsync( + SessionValidationResult.Invalid( + SessionState.NotFound)); + + await using var host = + await CreateMultiTenantHostAsync(validator.Object); + + AddSession(host.Client); + AddTenant(host.Client, "tenant-b"); + + var response = await host.Client.GetAsync( + "/__tests/resource/authenticated"); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task SameSessionId_ShouldRemainTenantScoped() + { + var validator = new Mock(); + + validator + .Setup(x => x.ValidateSessionAsync( + It.Is( + context => + context.Tenant == TenantA && + context.SessionId == SessionId), + It.IsAny())) + .ReturnsAsync(CreateActiveResult(TenantA)); + + validator + .Setup(x => x.ValidateSessionAsync( + It.Is( + context => + context.Tenant == TenantB && + context.SessionId == SessionId), + It.IsAny())) + .ReturnsAsync( + SessionValidationResult.Invalid( + SessionState.NotFound)); + + await using var host = + await CreateMultiTenantHostAsync(validator.Object); + + AddSession(host.Client); + AddTenant(host.Client, "tenant-a"); + + var tenantAResponse = await host.Client.GetAsync( + "/__tests/resource/authenticated"); + + tenantAResponse.StatusCode.Should() + .Be(HttpStatusCode.OK); + + host.Client.DefaultRequestHeaders.Remove(TenantHeader); + AddTenant(host.Client, "tenant-b"); + + var tenantBResponse = await host.Client.GetAsync( + "/__tests/resource/authenticated"); + + tenantBResponse.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task ResolvedTenant_ShouldBePreservedInValidationContext() + { + var observedTenants = new List(); + + var validator = new Mock(); + + validator + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (context, _) => + { + observedTenants.Add(context.Tenant); + }) + .ReturnsAsync( + (SessionValidationContext context, CancellationToken _) => + CreateActiveResult(context.Tenant)); + + await using var host = + await CreateMultiTenantHostAsync(validator.Object); + + AddSession(host.Client); + + AddTenant(host.Client, "tenant-a"); + + var tenantAResponse = await host.Client.GetAsync( + "/__tests/resource/authenticated"); + + host.Client.DefaultRequestHeaders.Remove(TenantHeader); + + AddTenant(host.Client, "tenant-b"); + + var tenantBResponse = await host.Client.GetAsync( + "/__tests/resource/authenticated"); + + tenantAResponse.StatusCode.Should() + .Be(HttpStatusCode.OK); + + tenantBResponse.StatusCode.Should() + .Be(HttpStatusCode.OK); + + observedTenants.Should() + .Contain(TenantA); + + observedTenants.Should() + .Contain(TenantB); + } + + private static SessionValidationResult CreateActiveResult( + TenantKey tenant) + { + return SessionValidationResult.Active( + tenant: tenant, + userKey: User, + sessionId: SessionId, + chainId: ChainId, + rootId: RootId, + claims: ClaimsSnapshot.Empty, + authenticatedAt: Now); + } + + private static void AddSession(HttpClient client) + { + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue( + "Bearer", + SessionValue); + } + + private static void AddTenant( + HttpClient client, + string tenant) + { + client.DefaultRequestHeaders.Add( + TenantHeader, + tenant); + } + + private static async Task CreateMultiTenantHostAsync(ISessionValidator validator) + { + return await ResourceApiTestHost.CreateAsync(validator, + configureResourceApi: options => + { + options.MultiTenant.Enabled = true; + options.MultiTenant.EnableHeader = true; + options.MultiTenant.HeaderName = TenantHeader; + }); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiTestHost.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiTestHost.cs new file mode 100644 index 00000000..db68a4b1 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiTestHost.cs @@ -0,0 +1,62 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Integration.ResourceApi; + +internal sealed class ResourceApiTestHost : IAsyncDisposable +{ + private readonly WebApplication _app; + + public HttpClient Client { get; } + + private ResourceApiTestHost( + WebApplication app, + HttpClient client) + { + _app = app; + Client = client; + } + + public static async Task CreateAsync(ISessionValidator sessionValidator, Action? configureServices = null, Action? configureResourceApi = null) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddControllers().AddApplicationPart(typeof(TestResourceController).Assembly); + + builder.Services.AddUltimateAuthResourceApi(options => + { + options.UAuthHubBaseUrl = "https://uauth.test"; + configureResourceApi?.Invoke(options); + }); + + builder.Services.AddScoped(_ => sessionValidator); + + configureServices?.Invoke(builder.Services); + + var app = builder.Build(); + + app.UseUltimateAuthResourceApiWithAspNetCore(); + + app.MapControllers(); + + await app.StartAsync(); + + return new ResourceApiTestHost( + app, + app.GetTestClient()); + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + + await _app.StopAsync(); + await _app.DisposeAsync(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/TestResourceController.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/TestResourceController.cs new file mode 100644 index 00000000..bef6205b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/TestResourceController.cs @@ -0,0 +1,66 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Tests.Integration.ResourceApi; + +[ApiController] +[Route("__tests/resource")] +public sealed class TestResourceController : ControllerBase +{ + public const string ProductsRead = "products.read.self"; + public const string ProductsUpdate = "products.update.admin"; + + [HttpGet("anonymous")] + [AllowAnonymous] + public IActionResult Anonymous() + { + return Ok(); + } + + [HttpGet("authenticated")] + [Authorize] + public IActionResult Authenticated() + { + return Ok(); + } + + [HttpGet("admin")] + [Authorize(Roles = "Admin")] + public IActionResult Admin() + { + return Ok(); + } + + [HttpGet("identity")] + [Authorize] + public IActionResult Identity() + { + return Ok(new + { + UserId = User.FindFirstValue(ClaimTypes.NameIdentifier), + Roles = User.FindAll(ClaimTypes.Role) + .Select(x => x.Value) + .ToArray(), + Permissions = User.FindAll("uauth:permission") + .Select(x => x.Value) + .ToArray() + }); + } + + [HttpGet("products/read")] + [Authorize(Policy = ProductsRead)] + public IActionResult ReadProducts() + { + return Ok(); + } + + [HttpPut("products/{id:int}")] + [Authorize(Policy = ProductsUpdate)] + public IActionResult UpdateProduct(int id) + { + return Ok(new { Id = id }); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/ResourceApi/UAuthResourceApiRegistrationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/ResourceApi/UAuthResourceApiRegistrationTests.cs new file mode 100644 index 00000000..275aa81f --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/ResourceApi/UAuthResourceApiRegistrationTests.cs @@ -0,0 +1,346 @@ +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Runtime; +using CodeBeam.UltimateAuth.Policies.Abstractions; +using CodeBeam.UltimateAuth.Server; +using CodeBeam.UltimateAuth.Server.Authentication; +using CodeBeam.UltimateAuth.Server.Authorization; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.ResourceApi; +using CodeBeam.UltimateAuth.Server.Runtime; +using FluentAssertions; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server.ResourceApi; + +public sealed class UAuthResourceApiRegistrationTests +{ + [Fact] + public void AddUltimateAuthResourceApi_ShouldRegisterResourceRuntimeMarker() + { + using var provider = CreateProvider(); + + var marker = provider.GetRequiredService(); + + marker.Should().BeOfType(); + } + + [Fact] + public void AddUltimateAuthResourceApi_ShouldRegisterRemoteSessionValidator() + { + using var provider = CreateProvider(); + + using var scope = provider.CreateScope(); + + var validator = + scope.ServiceProvider.GetRequiredService(); + + validator.Should().BeOfType(); + } + + [Fact] + public void AddUltimateAuthResourceApi_ShouldRegisterResourceAccessOrchestrator() + { + using var provider = CreateProvider(); + + using var scope = provider.CreateScope(); + + var orchestrator = + scope.ServiceProvider.GetRequiredService(); + + orchestrator.Should() + .BeOfType(); + } + + [Fact] + public void AddUltimateAuthResourceApi_ShouldRegisterTransportCredentialResolver() + { + using var provider = CreateProvider(); + + using var scope = provider.CreateScope(); + + var resolver = + scope.ServiceProvider.GetRequiredService(); + + resolver.Should() + .BeOfType(); + } + + [Fact] + public void AddUltimateAuthResourceApi_ShouldRegisterDeviceContextFactory() + { + using var provider = CreateProvider(); + + using var scope = provider.CreateScope(); + + var factory = + scope.ServiceProvider.GetRequiredService(); + + factory.Should() + .BeOfType(); + } + + [Fact] + public void AddUltimateAuthResourceApi_ShouldRegisterAuthorizationInfrastructure() + { + using var provider = CreateProvider(); + + using var scope = provider.CreateScope(); + + provider + .GetRequiredService() + .Should() + .BeOfType(); + + scope.ServiceProvider + .GetServices() + .Should() + .Contain(x => x is UAuthAuthorizationHandler); + } + + [Fact] + public void AddUltimateAuthResourceApi_ShouldConfigureUltimateAuthAsDefaultAuthenticationScheme() + { + using var provider = CreateProvider(); + + var options = + provider.GetRequiredService>() + .Value; + + options.DefaultAuthenticateScheme.Should() + .Be(UAuthConstants.SchemeDefaults.GlobalScheme); + + options.DefaultChallengeScheme.Should() + .Be(UAuthConstants.SchemeDefaults.GlobalScheme); + } + + [Fact] + public async Task AddUltimateAuthResourceApi_ShouldRegisterAuthenticationScheme() + { + using var provider = CreateProvider(); + + var schemeProvider = provider.GetRequiredService(); + + var scheme = await schemeProvider.GetSchemeAsync(UAuthConstants.SchemeDefaults.GlobalScheme); + + scheme.Should().NotBeNull(); + + scheme!.HandlerType.Should().Be(typeof(UAuthResourceAuthenticationHandler)); + } + + [Fact] + public void AddUltimateAuthResourceApi_ShouldRegisterResourceUserAccessor() + { + using var provider = CreateProvider(); + + using var scope = provider.CreateScope(); + + var accessor = + scope.ServiceProvider.GetRequiredService>(); + + accessor.Should() + .BeOfType>(); + } + + [Fact] + public void AddUltimateAuthResourceApi_ShouldRegisterAccessPolicyProvider() + { + using var provider = CreateProvider(); + + using var scope = provider.CreateScope(); + + var policyProvider = + scope.ServiceProvider.GetRequiredService(); + + policyProvider.Should() + .BeOfType(); + } + + [Fact] + public void AddUltimateAuthResourceApi_WhenNoTenantResolverIsEnabled_ShouldUseNullTenantResolver() + { + using var provider = CreateProvider(options => + { + options.MultiTenant.EnableRoute = false; + options.MultiTenant.EnableHeader = false; + options.MultiTenant.EnableDomain = false; + }); + + var resolver = + provider.GetRequiredService(); + + resolver.Should() + .BeOfType(); + } + + [Fact] + public async Task AddUltimateAuthResourceApi_WhenNoTenantResolverIsEnabled_ShouldResolveNoTenant() + { + using var provider = CreateProvider(options => + { + options.MultiTenant.EnableRoute = false; + options.MultiTenant.EnableHeader = false; + options.MultiTenant.EnableDomain = false; + }); + + var resolver = + provider.GetRequiredService(); + + var tenantId = await resolver.ResolveTenantIdAsync( + TenantResolutionContext.Empty); + + tenantId.Should().BeNull(); + } + + [Fact] + public void AddUltimateAuthResourceApi_WhenAllowedOriginsConfigured_ShouldRegisterCorsPolicy() + { + using var provider = CreateProvider(options => + { + options.AllowedClientOrigins = + [ + "https://client.example.com" + ]; + }); + + var options = + provider.GetRequiredService>() + .Value; + + var policy = options.GetPolicy( + provider.GetRequiredService>() + .Value + .CorsPolicyName); + + policy.Should().NotBeNull(); + + policy!.Origins.Should() + .ContainSingle() + .Which.Should() + .Be("https://client.example.com"); + + policy.AllowAnyHeader.Should().BeTrue(); + policy.AllowAnyMethod.Should().BeTrue(); + policy.SupportsCredentials.Should().BeTrue(); + } + + [Fact] + public void AddUltimateAuthResourceApi_WithHubBaseUrl_ShouldResolveSessionValidator() + { + using var provider = CreateProvider(options => + { + options.UAuthHubBaseUrl = "https://hub.example.com"; + }); + + using var scope = provider.CreateScope(); + + var act = () => + scope.ServiceProvider.GetRequiredService(); + + act.Should().NotThrow(); + + act().Should() + .BeOfType(); + } + + [Fact] + public void AddUltimateAuthResourceApi_ShouldPreserveMultiTenantConfiguration() + { + var services = new ServiceCollection(); + + var configuration = new ConfigurationBuilder() + .Build(); + + services.AddSingleton(configuration); + services.AddLogging(); + + services.AddUltimateAuthResourceApi(options => + { + options.UAuthHubBaseUrl = "https://uauth.test"; + + options.MultiTenant.Enabled = true; + options.MultiTenant.EnableHeader = true; + options.MultiTenant.EnableRoute = false; + options.MultiTenant.EnableDomain = false; + options.MultiTenant.HeaderName = "X-Tenant"; + }); + + using var provider = services.BuildServiceProvider(); + + var options = provider + .GetRequiredService>() + .Value; + + options.MultiTenant.Enabled.Should().BeTrue(); + options.MultiTenant.EnableHeader.Should().BeTrue(); + options.MultiTenant.EnableRoute.Should().BeFalse(); + options.MultiTenant.EnableDomain.Should().BeFalse(); + options.MultiTenant.HeaderName.Should().Be("X-Tenant"); + } + + [Fact] + public async Task AddUltimateAuthResourceApi_WithHeaderTenantEnabled_ShouldResolveHeaderTenant() + { + var services = new ServiceCollection(); + + var configuration = new ConfigurationBuilder() + .Build(); + + services.AddSingleton(configuration); + services.AddLogging(); + + services.AddUltimateAuthResourceApi(options => + { + options.UAuthHubBaseUrl = "https://uauth.test"; + + options.MultiTenant.Enabled = true; + options.MultiTenant.EnableHeader = true; + options.MultiTenant.HeaderName = "X-Tenant"; + }); + + using var provider = services.BuildServiceProvider(); + + var resolver = + provider.GetRequiredService(); + + var context = TenantResolutionContext.Create( + headers: new Dictionary + { + ["X-Tenant"] = "tenant-a" + }); + + var result = + await resolver.ResolveTenantIdAsync(context); + + result.Should().Be("tenant-a"); + } + + + private static ServiceProvider CreateProvider(Action? configure = null) + { + var services = new ServiceCollection(); + + var configuration = new ConfigurationBuilder() + .Build(); + + services.AddSingleton(configuration); + + services.AddLogging(); + + services.AddUltimateAuthResourceApi(options => + { + options.UAuthHubBaseUrl = "https://uauth.test"; + + configure?.Invoke(options); + }); + + return services.BuildServiceProvider(); + } +} From 57868ce93c103a8d90f7237476baf52a0c4bafe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Tue, 6 Oct 2026 18:15:02 +0300 Subject: [PATCH 13/16] Fix Missing Session, Chain, Root & Device Data on Hub & Resource Processes --- .../Contracts/Session/AuthValidationResult.cs | 8 + .../Session/Dtos/SessionValidationInfo.cs | 10 - .../Infrastructure/SessionValidationMapper.cs | 144 +++--- .../Endpoints/ValidateEndpointHandler.cs | 42 +- .../UAuthJwtValidator.cs | 2 +- .../MultiTenancy/UAuthTenantContextFactory.cs | 28 -- .../ResourceApi/RemoteSessionValidator.cs | 4 +- .../RemoteSessionValidatorTests.cs | 451 ++++++++++++++++++ .../Server/ValidateEndpointHandlerTests.cs | 117 ++++- 9 files changed, 677 insertions(+), 129 deletions(-) delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionValidationInfo.cs rename src/CodeBeam.UltimateAuth.Server/{Services => Infrastructure}/UAuthJwtValidator.cs (98%) delete mode 100644 src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantContextFactory.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/ResourceApi/RemoteSessionValidatorTests.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs index 7a617737..bc4d9c9e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs @@ -2,10 +2,18 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; +// TODO: Validation protocol review after SemiHybrid/PureJwt: Re-evaluate exposure and necessity of +// ChainId, RootId, and BoundDeviceId in AuthValidationResult once all AuthModes have concrete validation semantics. +// Prefer the smallest common public contract and keep security lineage internal where possible. public sealed record AuthValidationResult { public required SessionState State { get; init; } public AuthStateSnapshot? Snapshot { get; init; } + public Guid? ChainId { get; init; } + + public Guid? RootId { get; init; } + + public string? BoundDeviceId { get; init; } public bool IsValid => State == SessionState.Active; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionValidationInfo.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionValidationInfo.cs deleted file mode 100644 index c4519080..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionValidationInfo.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts; - -public sealed class SessionValidationInfo -{ - public int State { get; set; } = default!; - - public bool IsValid { get; set; } - - public AuthSnapshotInfo? Snapshot { get; set; } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs index f46ced1f..bb4458f2 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs @@ -1,92 +1,122 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Core.Infrastructure; public static class SessionValidationMapper { - public static SessionValidationResult ToDomain(SessionValidationInfo dto) + public static SessionValidationResult ToDomain(AuthValidationResult dto, AuthSessionId sessionId) { - var state = (SessionState)dto.State; + ArgumentNullException.ThrowIfNull(dto); - if (!dto.IsValid || dto.Snapshot?.Identity is null) + if (!dto.IsValid) { - return SessionValidationResult.Invalid(state); + return SessionValidationResult.Invalid( + dto.State, + sessionId: sessionId, + chainId: TryParseChainId(dto.ChainId), + rootId: TryParseRootId(dto.RootId), + boundDeviceId: TryParseDeviceId(dto.BoundDeviceId)); } - var tenant = TenantKey.FromInternal(dto.Snapshot.Identity.Tenant); + // + // Active is a stronger contract than merely receiving + // a successful HTTP response. All required security + // lineage and identity data must be present. + // - UserKey? userKey = string.IsNullOrWhiteSpace(dto.Snapshot.Identity.UserKey) - ? null - : UserKey.Parse(dto.Snapshot.Identity.UserKey, null); - - ClaimsSnapshot claims; - - if (dto.Snapshot.Claims is null) + if (dto.Snapshot?.Identity is null) { - claims = ClaimsSnapshot.Empty; + return SessionValidationResult.Invalid( + SessionState.Invalid, + sessionId: sessionId); } - else + + if (dto.ChainId is not Guid chainGuid || + chainGuid == Guid.Empty) { - var builder = ClaimsSnapshot.Create(); + return SessionValidationResult.Invalid( + SessionState.Invalid, + sessionId: sessionId); + } - foreach (var (type, values) in dto.Snapshot.Claims.Claims) - { - builder.AddMany(type, values); - } + var chainId = + SessionChainId.From(chainGuid); - foreach (var role in dto.Snapshot.Claims.Roles) - { - builder.AddRole(role); - } + if (dto.RootId is not Guid rootGuid || + rootGuid == Guid.Empty) + { + return SessionValidationResult.Invalid( + SessionState.Invalid, + sessionId: sessionId, + chainId: chainId); + } - foreach (var permission in dto.Snapshot.Claims.Permissions) - { - builder.AddPermission(permission); - } + var rootId = + SessionRootId.From(rootGuid); - claims = builder.Build(); - } - - AuthSessionId.TryCreate("temp", out AuthSessionId tempSessionId); + var identity = + dto.Snapshot.Identity; + + var boundDeviceId = + TryParseDeviceId(dto.BoundDeviceId); return SessionValidationResult.Active( - tenant, - userKey, - tempSessionId, // TODO: This is TEMP add real - SessionChainId.New(), // TEMP - SessionRootId.New(), // TEMP - claims, - dto.Snapshot.Identity.AuthenticatedAt ?? DateTimeOffset.UtcNow, - null - ); + tenant: identity.Tenant, + userKey: identity.UserKey, + sessionId: sessionId, + chainId: chainId, + rootId: rootId, + claims: dto.Snapshot.Claims, + authenticatedAt: + identity.AuthenticatedAt + ?? DateTimeOffset.UtcNow, + boundDeviceId: boundDeviceId); } public static SessionSecurityContext? ToSecurityContext(SessionValidationResult result) { - if (!result.IsValid) - { - if (result?.SessionId is null) - return null; - - return new SessionSecurityContext - { - SessionId = result.SessionId.Value, - State = result.State, - ChainId = result.ChainId, - UserKey = result.UserKey, - BoundDeviceId = result.BoundDeviceId - }; - } + if (result.SessionId is null) + return null; return new SessionSecurityContext { - SessionId = result.SessionId!.Value, - State = SessionState.Active, + SessionId = result.SessionId.Value, + State = result.State, ChainId = result.ChainId, UserKey = result.UserKey, BoundDeviceId = result.BoundDeviceId }; } + + private static SessionChainId? TryParseChainId(Guid? value) + { + if (value is not Guid guid || + guid == Guid.Empty) + { + return null; + } + + return SessionChainId.From(guid); + } + + private static SessionRootId? TryParseRootId(Guid? value) + { + if (value is not Guid guid || + guid == Guid.Empty) + { + return null; + } + + return SessionRootId.From(guid); + } + + private static DeviceId? TryParseDeviceId(string? value) + { + return DeviceId.TryCreate( + value, + out var id) + ? id + : null; + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs index b0875a33..bbe6fc31 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs @@ -40,7 +40,7 @@ public async Task ValidateAsync(HttpContext context, CancellationToken return Results.Json( new AuthValidationResult { - State = SessionState.NotFound + State = SessionState.NotFound, }, statusCode: StatusCodes.Status401Unauthorized ); @@ -53,7 +53,7 @@ public async Task ValidateAsync(HttpContext context, CancellationToken return Results.Json( new AuthValidationResult { - State = SessionState.Invalid + State = SessionState.Invalid, }, statusCode: StatusCodes.Status401Unauthorized ); @@ -71,12 +71,26 @@ public async Task ValidateAsync(HttpContext context, CancellationToken }, ct); - if (result.UserKey is not UserKey userKey) + if (!result.IsValid) + { + return Results.Ok(new AuthValidationResult + { + State = result.State, + ChainId = result.ChainId?.Value, + RootId = result.RootId?.Value, + BoundDeviceId = result.BoundDeviceId?.Value + }); + } + + if (result.UserKey is not UserKey) { return Results.Json( new AuthValidationResult { - State = SessionState.Invalid + State = SessionState.Invalid, + ChainId = result.ChainId?.Value, + RootId = result.RootId?.Value, + BoundDeviceId = result.BoundDeviceId?.Value }, statusCode: StatusCodes.Status401Unauthorized ); @@ -84,9 +98,27 @@ public async Task ValidateAsync(HttpContext context, CancellationToken var snapshot = await _snapshotFactory.CreateAsync(result, ct); + if (snapshot is null) + { + return Results.Json( + new AuthValidationResult + { + State = SessionState.Invalid, + ChainId = result.ChainId?.Value, + RootId = result.RootId?.Value, + BoundDeviceId = result.BoundDeviceId?.Value + }, + statusCode: + StatusCodes.Status401Unauthorized); + } + return Results.Ok(new AuthValidationResult { - State = result.IsValid ? SessionState.Active : result.State, + State = SessionState.Active, + ChainId = result.ChainId?.Value, + RootId = result.RootId?.Value, + BoundDeviceId = result.BoundDeviceId?.Value, + Snapshot = snapshot }); } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthJwtValidator.cs similarity index 98% rename from src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthJwtValidator.cs index 18adbfcd..34bc9835 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthJwtValidator.cs @@ -6,7 +6,7 @@ using System.Security.Claims; using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Server.Services; +namespace CodeBeam.UltimateAuth.Server.Infrastructure; internal sealed class UAuthJwtValidator : IJwtValidator { diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantContextFactory.cs deleted file mode 100644 index 1612e00a..00000000 --- a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantContextFactory.cs +++ /dev/null @@ -1,28 +0,0 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Core.Options; - -namespace CodeBeam.UltimateAuth.Server.MultiTenancy; - -public static class UAuthTenantContextFactory -{ - public static UAuthTenantContext Create(string? rawTenantId, UAuthMultiTenantOptions options) - { - if (!options.Enabled) - return UAuthTenantContext.SingleTenant(); - - if (string.IsNullOrWhiteSpace(rawTenantId)) - { - //if (options.RequireTenant) - // throw new InvalidOperationException("Tenant is required but could not be resolved."); - - throw new InvalidOperationException("Tenant could not be resolved."); - } - - var tenantId = options.NormalizeToLowercase - ? rawTenantId.Trim().ToLowerInvariant() - : rawTenantId.Trim(); - - var tenantKey = TenantKey.FromExternal(tenantId); - return UAuthTenantContext.Resolved(tenantKey); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs index 092fbba4..14429d4c 100644 --- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs @@ -41,11 +41,11 @@ public async Task ValidateSessionAsync(SessionValidatio if (!response.IsSuccessStatusCode) return SessionValidationResult.Invalid(SessionState.NotFound, sessionId: context.SessionId); - var dto = await response.Content.ReadFromJsonAsync(cancellationToken: ct); + var dto = await response.Content.ReadFromJsonAsync(cancellationToken: ct); if (dto is null) return SessionValidationResult.Invalid(SessionState.NotFound, sessionId: context.SessionId); - return SessionValidationMapper.ToDomain(dto); + return SessionValidationMapper.ToDomain(dto, context.SessionId); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/ResourceApi/RemoteSessionValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/ResourceApi/RemoteSessionValidatorTests.cs new file mode 100644 index 00000000..8c6b835b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/ResourceApi/RemoteSessionValidatorTests.cs @@ -0,0 +1,451 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.ResourceApi; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server.ResourceApi; + +public sealed class RemoteSessionValidatorTests +{ + private const string SessionValue = + "session-00000000000000000000000000000001"; + + private static readonly TenantKey Tenant = + TenantKey.FromExternal("tenant-a"); + + [Fact] + public async Task ValidateSessionAsync_ShouldPostSessionAndTenantToValidationEndpoint() + { + var handler = new TestHttpMessageHandler + { + Response = CreateFailureResponse(HttpStatusCode.Unauthorized) + }; + + var sut = CreateSut(handler); + + var context = CreateValidationContext(); + + await sut.ValidateSessionAsync(context); + + handler.Method.Should().Be(HttpMethod.Post); + + handler.RequestUri.Should() + .Be(new Uri("https://uauth.test/auth/validate")); + + handler.Body.Should().NotBeNullOrWhiteSpace(); + + using var json = JsonDocument.Parse(handler.Body!); + + json.RootElement + .GetProperty("sessionId") + .GetString() + .Should() + .Be(context.SessionId.Value); + + json.RootElement + .GetProperty("tenant") + .GetString() + .Should() + .Be(Tenant.Value); + } + + [Fact] + public async Task ValidateSessionAsync_WhenIncomingRequestContainsCookie_ShouldForwardCookie() + { + var handler = new TestHttpMessageHandler + { + Response = CreateFailureResponse(HttpStatusCode.Unauthorized) + }; + + var httpContext = new DefaultHttpContext(); + + httpContext.Request.Headers.Cookie = + "uauth_session=session-cookie; other=value"; + + var sut = CreateSut( + handler, + httpContext); + + await sut.ValidateSessionAsync( + CreateValidationContext()); + + handler.Cookie.Should() + .Be("uauth_session=session-cookie; other=value"); + } + + [Fact] + public async Task ValidateSessionAsync_WhenIncomingRequestDoesNotContainCookie_ShouldNotAddCookieHeader() + { + var handler = new TestHttpMessageHandler + { + Response = CreateFailureResponse(HttpStatusCode.Unauthorized) + }; + + var sut = CreateSut(handler); + + await sut.ValidateSessionAsync( + CreateValidationContext()); + + handler.Cookie.Should().BeNull(); + } + + [Theory] + [InlineData(HttpStatusCode.BadRequest)] + [InlineData(HttpStatusCode.Unauthorized)] + [InlineData(HttpStatusCode.Forbidden)] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + public async Task ValidateSessionAsync_WhenHubReturnsNonSuccess_ShouldReturnNotFound( + HttpStatusCode statusCode) + { + var handler = new TestHttpMessageHandler + { + Response = CreateFailureResponse(statusCode) + }; + + var sut = CreateSut(handler); + + var context = CreateValidationContext(); + + var result = + await sut.ValidateSessionAsync(context); + + result.IsValid.Should().BeFalse(); + result.State.Should().Be(SessionState.NotFound); + + result.SessionId.Should() + .Be(context.SessionId); + } + + [Fact] + public async Task ValidateSessionAsync_WhenHubReturnsJsonNull_ShouldReturnNotFound() + { + var handler = new TestHttpMessageHandler + { + Response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(null) + } + }; + + var sut = CreateSut(handler); + + var context = CreateValidationContext(); + + var result = + await sut.ValidateSessionAsync(context); + + result.IsValid.Should().BeFalse(); + result.State.Should().Be(SessionState.NotFound); + + result.SessionId.Should() + .Be(context.SessionId); + } + + [Fact] + public async Task ValidateSessionAsync_WhenHubReturnsInvalidResult_ShouldMapResult() + { + var dto = new AuthValidationResult + { + State = SessionState.Revoked, + Snapshot = null + }; + + var handler = new TestHttpMessageHandler + { + Response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(dto) + } + }; + + var sut = CreateSut(handler); + + var result = + await sut.ValidateSessionAsync( + CreateValidationContext()); + + result.IsValid.Should().BeFalse(); + result.State.Should().Be(SessionState.Revoked); + } + + [Fact] + public async Task ValidateSessionAsync_WhenCancelled_ShouldPropagateCancellation() + { + var handler = new TestHttpMessageHandler + { + OnSendAsync = async (_, ct) => + { + await Task.Delay(Timeout.InfiniteTimeSpan, ct); + + return new HttpResponseMessage(HttpStatusCode.OK); + } + }; + + var sut = CreateSut(handler); + + using var cts = new CancellationTokenSource(); + + var task = sut.ValidateSessionAsync( + CreateValidationContext(), + cts.Token); + + cts.Cancel(); + + var act = async () => await task; + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task ValidateSessionAsync_WhenHubReturnsValidSnapshot_ShouldReturnActiveResult() + { + var authenticatedAt = + new DateTimeOffset( + 2026, 1, 1, 10, 30, 0, + TimeSpan.Zero); + + var sessionId = + AuthSessionId.Parse(SessionValue, null); + + var chainId = SessionChainId.New(); + var rootId = SessionRootId.New(); + + var dto = new AuthValidationResult + { + State = SessionState.Active, + ChainId = chainId.Value, + RootId = rootId.Value, + + Snapshot = new AuthStateSnapshot + { + Identity = new AuthIdentitySnapshot + { + Tenant = Tenant, + UserKey = UserKey.Parse("user-123", null), + AuthenticatedAt = authenticatedAt + }, + Claims = ClaimsSnapshot.Empty + } + }; + + var handler = new TestHttpMessageHandler + { + Response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(dto) + } + }; + + var sut = CreateSut(handler); + + var result = await sut.ValidateSessionAsync( + CreateValidationContext()); + + result.IsValid.Should().BeTrue(); + result.State.Should().Be(SessionState.Active); + + result.Tenant.Should().Be(Tenant); + result.UserKey.Should() + .Be(UserKey.Parse("user-123", null)); + + result.SessionId.Should().Be(sessionId); + result.ChainId.Should().Be(chainId); + result.RootId.Should().Be(rootId); + + result.AuthenticatedAt.Should().Be(authenticatedAt); + result.Claims.Should().NotBeNull(); + } + + + [Fact] + public async Task ValidateSessionAsync_WhenHubReturnsValidWithoutIdentity_ShouldReturnInvalid() + { + var dto = new AuthValidationResult + { + State = SessionState.Active, + ChainId = SessionChainId.New().Value, + RootId = SessionRootId.New().Value, + + Snapshot = new AuthStateSnapshot + { + Identity = null!, + Claims = ClaimsSnapshot.Empty + } + }; + + var handler = new TestHttpMessageHandler + { + Response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(dto) + } + }; + + var sut = CreateSut(handler); + + var result = await sut.ValidateSessionAsync( + CreateValidationContext()); + + result.IsValid.Should().BeFalse(); + } + + [Fact] + public async Task ValidateSessionAsync_WhenHubReturnsValidWithoutSnapshot_ShouldReturnInvalid() + { + var dto = new AuthValidationResult + { + State = SessionState.Active, + ChainId = SessionChainId.New().Value, + RootId = SessionRootId.New().Value, + + Snapshot = null + }; + + var handler = new TestHttpMessageHandler + { + Response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(dto) + } + }; + + var sut = CreateSut(handler); + + var result = await sut.ValidateSessionAsync( + CreateValidationContext()); + + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void ToDomain_WhenIsValidFalseButStateActive_ShouldFailClosed() + { + var dto = new AuthValidationResult + { + State = SessionState.Active, + }; + + var result = SessionValidationMapper.ToDomain(dto, AuthSessionId.Parse(SessionValue, null)); + + result.IsValid.Should().BeFalse(); + result.State.Should().NotBe(SessionState.Active); + } + + [Fact] + public void ToDomain_WhenIsValidTrueButStateIsRevoked_ShouldFailClosed() + { + var dto = new AuthValidationResult + { + State = SessionState.Revoked, + ChainId = SessionChainId.New().Value, + RootId = SessionRootId.New().Value, + + Snapshot = new AuthStateSnapshot + { + Identity = new AuthIdentitySnapshot + { + Tenant = Tenant, + UserKey = UserKey.Parse( + "user-1", + null) + }, + Claims = ClaimsSnapshot.Empty + } + }; + + var result = + SessionValidationMapper.ToDomain(dto, AuthSessionId.Parse(SessionValue, null)); + + result.IsValid.Should().BeFalse(); + result.State.Should().Be(SessionState.Revoked); + } + + + private static RemoteSessionValidator CreateSut(TestHttpMessageHandler handler, HttpContext? httpContext = null) + { + var httpClient = new HttpClient(handler) + { + BaseAddress = + new Uri("https://uauth.test") + }; + + var accessor = + new HttpContextAccessor + { + HttpContext = + httpContext ?? new DefaultHttpContext() + }; + + return new RemoteSessionValidator( + httpClient, + accessor); + } + + private static SessionValidationContext CreateValidationContext() + { + return new SessionValidationContext + { + Tenant = Tenant, + + SessionId = AuthSessionId.Parse(SessionValue, null), + + Now = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero), + + Device = DeviceContext.Anonymous() + }; + } + + private static HttpResponseMessage CreateFailureResponse(HttpStatusCode statusCode) + { + return new HttpResponseMessage(statusCode); + } + + private sealed class TestHttpMessageHandler : HttpMessageHandler + { + public HttpResponseMessage Response { get; set; } = new(HttpStatusCode.OK); + + public Func>? OnSendAsync { get; set; } + + public HttpMethod? Method { get; private set; } + public Uri? RequestUri { get; private set; } + public string? Body { get; private set; } + public string? Cookie { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Method = request.Method; + RequestUri = request.RequestUri; + + if (request.Content is not null) + { + Body = await request.Content.ReadAsStringAsync( + cancellationToken); + } + + if (request.Headers.TryGetValues( + "Cookie", + out var cookieValues)) + { + Cookie = cookieValues.SingleOrDefault(); + } + + if (OnSendAsync is not null) + { + return await OnSendAsync( + request, + cancellationToken); + } + + return Response; + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ValidateEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ValidateEndpointHandlerTests.cs index 0784187f..2a06ca4e 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ValidateEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ValidateEndpointHandlerTests.cs @@ -7,7 +7,6 @@ using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Contracts; using CodeBeam.UltimateAuth.Server.Endpoints; -using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using FluentAssertions; @@ -52,6 +51,7 @@ public async Task ValidateAsync_WhenCredentialIsMissing_ReturnsUnauthorizedNotFo json.Value!.State.Should() .Be(SessionState.NotFound); + json.Value.IsValid.Should().BeFalse(); json.Value.Snapshot.Should().BeNull(); fixture.SessionValidator.VerifyNoOtherCalls(); @@ -136,6 +136,7 @@ public async Task ValidateAsync_WhenSessionCredentialCannotBeParsed_ReturnsUnaut json.Value!.State.Should() .Be(SessionState.Invalid); + json.Value.IsValid.Should().BeFalse(); json.Value.Snapshot.Should().BeNull(); fixture.SessionValidator.VerifyNoOtherCalls(); @@ -210,7 +211,7 @@ await fixture.Sut.ValidateAsync( // ===================================================================== [Fact] - public async Task ValidateAsync_WhenValidationHasNoUserKey_ReturnsUnauthorizedInvalid() + public async Task ValidateAsync_WhenValidationIsInvalidWithoutUserKey_ReturnsOkInvalid() { var fixture = CreateFixture(); @@ -238,18 +239,18 @@ public async Task ValidateAsync_WhenValidationHasNoUserKey_ReturnsUnauthorizedIn fixture.HttpContext, fixture.CancellationToken); - var json = result.Should() - .BeOfType>() + var ok = result.Should() + .BeOfType>() .Subject; - json.StatusCode.Should() - .Be(StatusCodes.Status401Unauthorized); + ok.Value.Should().NotBeNull(); - json.Value.Should().NotBeNull(); - json.Value!.State.Should() + ok.Value!.State.Should() .Be(SessionState.Invalid); - json.Value.Snapshot.Should().BeNull(); + ok.Value.IsValid.Should().BeFalse(); + + ok.Value.Snapshot.Should().BeNull(); fixture.SnapshotFactory.Verify( x => x.CreateAsync( @@ -304,15 +305,26 @@ public async Task ValidateAsync_WhenSessionIsValid_ReturnsOkActiveWithSnapshot() ok.Value.Should().NotBeNull(); - ok.Value!.State.Should() + var value = ok.Value!; + + value.State.Should() .Be(SessionState.Active); - ok.Value.Snapshot.Should() + value.IsValid.Should().BeTrue(); + + value.Snapshot.Should() .BeSameAs(snapshot); - fixture.SnapshotFactory.Verify(x => x.CreateAsync( - validation, - It.IsAny()), + value.ChainId.Should() + .Be(validation.ChainId!.Value.Value); + + value.RootId.Should() + .Be(validation.RootId!.Value.Value); + + fixture.SnapshotFactory.Verify( + x => x.CreateAsync( + validation, + It.IsAny()), Times.Once); } @@ -347,12 +359,6 @@ public async Task ValidateAsync_WhenValidationIsInvalidButContainsUserKey_Return fixture.CancellationToken)) .ReturnsAsync(validation); - fixture.SnapshotFactory - .Setup(x => x.CreateAsync( - validation, - It.IsAny())) - .ReturnsAsync((AuthStateSnapshot?)null); - var result = await fixture.Sut.ValidateAsync( fixture.HttpContext, fixture.CancellationToken); @@ -363,15 +369,20 @@ public async Task ValidateAsync_WhenValidationIsInvalidButContainsUserKey_Return ok.Value.Should().NotBeNull(); - ok.Value!.State.Should() + var value = ok.Value!; + + value.State.Should() .Be(SessionState.Revoked); - ok.Value.Snapshot.Should().BeNull(); + value.IsValid.Should().BeFalse(); - fixture.SnapshotFactory.Verify(x => x.CreateAsync( - validation, - It.IsAny()), - Times.Once); + value.Snapshot.Should().BeNull(); + + fixture.SnapshotFactory.Verify( + x => x.CreateAsync( + It.IsAny(), + It.IsAny()), + Times.Never); } // ===================================================================== @@ -417,6 +428,60 @@ await fixture.Sut.ValidateAsync( Times.Once); } + [Fact] + public async Task ValidateAsync_WhenValidationIsActiveButHasNoUserKey_ReturnsUnauthorizedInvalid() + { + var fixture = CreateFixture(); + + var sessionId = + TestIds.Session("active-without-user"); + + SetupSessionCredential( + fixture, + sessionId); + + var validation = + SessionValidationResult.Active( + tenant: fixture.Flow.Tenant, + userKey: null, + sessionId: sessionId, + chainId: SessionChainId.New(), + rootId: SessionRootId.New(), + claims: ClaimsSnapshot.Empty, + authenticatedAt: Now); + + fixture.SessionValidator + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), + fixture.CancellationToken)) + .ReturnsAsync(validation); + + var result = await fixture.Sut.ValidateAsync( + fixture.HttpContext, + fixture.CancellationToken); + + var json = result.Should() + .BeOfType>() + .Subject; + + json.StatusCode.Should() + .Be(StatusCodes.Status401Unauthorized); + + json.Value.Should().NotBeNull(); + + json.Value!.State.Should() + .Be(SessionState.Invalid); + + json.Value.IsValid.Should().BeFalse(); + json.Value.Snapshot.Should().BeNull(); + + fixture.SnapshotFactory.Verify( + x => x.CreateAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + // ===================================================================== // Helpers // ===================================================================== From ad124e2e3f573a424a7b6fe789b2f790b77beff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Tue, 6 Oct 2026 21:11:07 +0300 Subject: [PATCH 14/16] Issuer Tests --- .../Issuers/UAuthSessionIssuer.cs | 12 +- .../Issuers/UAuthTokenIssuer.cs | 26 +- .../Infrastructure/UAuthJwtValidator.cs | 140 +- .../Helpers/AuthFlowTestFactory.cs | 5 +- .../Sessions/UAuthSessionIssuerTests.cs | 1958 +++++++++++++++++ .../Tokens/UAuthTokenIssuerTests.cs | 905 ++++++++ 6 files changed, 2960 insertions(+), 86 deletions(-) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/UAuthSessionIssuerTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs index 186ed176..a37ca6d6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs @@ -65,6 +65,12 @@ await kernel.ExecuteAsync(async _ => { var existing = await kernel.GetChainAsync(context.ChainId.Value); + // TODO(v0.x): Re-evaluate explicit ChainId semantics. + // When the caller explicitly supplies a ChainId but that chain cannot be found, + // the current behavior silently creates a new chain with a different ChainId. + // Once SemiHybrid/PureJwt and the complete chain lifecycle semantics are finalized, + // decide whether this should instead fail closed (e.g. chain-not-found/validation failure). + // Do not change without reviewing login, refresh, reauthentication and device-chain flows. if (existing is null) { chain = UAuthSessionChain.Create( @@ -229,15 +235,15 @@ await kernel.ExecuteAsync(async _ => metadata: context.Metadata ); + var newSession = newSessionUnbound.WithChain(chain.ChainId); + issued = new IssuedSession { - Session = newSessionUnbound, + Session = newSession, OpaqueSessionId = opaqueSessionId, IsMetadataOnly = context.Mode == UAuthMode.SemiHybrid }; - var newSession = issued.Session.WithChain(chain.ChainId); - await kernel.CreateSessionAsync(newSession); var chainExpected = chain.Version; var updatedChain = chain.RotateSession(newSession.SessionId, now, context.Claims); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs index 290b399e..c4d139fb 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs @@ -77,10 +77,11 @@ UAuthMode.SemiHybrid or if (persistence == RefreshTokenPersistence.Persist) { var store = _storeFactory.Create(flow.Tenant); - await store.ExecuteAsync(async ct => + + await store.ExecuteAsync(async transactionCt => { - await store.StoreAsync(stored, ct); - }); + await store.StoreAsync(stored, transactionCt); + }, ct); } return new RefreshTokenInfo @@ -106,17 +107,20 @@ private AccessToken IssueOpaqueAccessToken(DateTimeOffset expires, string? sessi private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, UAuthTokenOptions tokens, DateTimeOffset expires) { - var claims = new Dictionary - { - ["sub"] = context.UserKey.Value, - ["tenant"] = context.Tenant - }; + var claims = new Dictionary(); + // Custom/application claims are added first. + // Framework-owned security claims below always take precedence. foreach (var kv in context.Claims) claims[kv.Key] = kv.Value; - if (context.SessionId != null) - claims["sid"] = context.SessionId!; + // UltimateAuth-owned identity/security claims must never be overridable + // by caller-provided claims. + claims["sub"] = context.UserKey.Value; + claims["tenant"] = context.Tenant; + + if (context.SessionId is AuthSessionId sessionId) + claims["sid"] = sessionId; if (tokens.AddJwtIdClaim) claims["jti"] = _opaqueGenerator.GenerateJwtId(); @@ -140,7 +144,7 @@ private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, UAuthToken Token = jwt, Format = TokenFormat.Jwt, ExpiresAt = expires, - SessionId = context.SessionId.ToString() + SessionId = context.SessionId?.ToString() }; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthJwtValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthJwtValidator.cs index 34bc9835..acf118f1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthJwtValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthJwtValidator.cs @@ -1,83 +1,83 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Contracts; -using Microsoft.IdentityModel.JsonWebTokens; -using Microsoft.IdentityModel.Tokens; -using System.Security.Claims; -using CodeBeam.UltimateAuth.Core.MultiTenancy; +//using CodeBeam.UltimateAuth.Core.Abstractions; +//using CodeBeam.UltimateAuth.Core.Domain; +//using CodeBeam.UltimateAuth.Core.Contracts; +//using Microsoft.IdentityModel.JsonWebTokens; +//using Microsoft.IdentityModel.Tokens; +//using System.Security.Claims; +//using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Server.Infrastructure; +//namespace CodeBeam.UltimateAuth.Server.Infrastructure; -internal sealed class UAuthJwtValidator : IJwtValidator -{ - private readonly JsonWebTokenHandler _jwtHandler; - private readonly TokenValidationParameters _jwtParameters; - private readonly IUserIdConverterResolver _converters; +//internal sealed class UAuthJwtValidator : IJwtValidator +//{ +// private readonly JsonWebTokenHandler _jwtHandler; +// private readonly TokenValidationParameters _jwtParameters; +// private readonly IUserIdConverterResolver _converters; - public UAuthJwtValidator(TokenValidationParameters jwtParameters, IUserIdConverterResolver converters) - { - _jwtHandler = new JsonWebTokenHandler(); - _jwtParameters = jwtParameters; - _converters = converters; - } +// public UAuthJwtValidator(TokenValidationParameters jwtParameters, IUserIdConverterResolver converters) +// { +// _jwtHandler = new JsonWebTokenHandler(); +// _jwtParameters = jwtParameters; +// _converters = converters; +// } - public async Task> ValidateAsync(string token, CancellationToken ct = default) - { - var result = await _jwtHandler.ValidateTokenAsync(token, _jwtParameters); +// public async Task> ValidateAsync(string token, CancellationToken ct = default) +// { +// var result = await _jwtHandler.ValidateTokenAsync(token, _jwtParameters); - if (!result.IsValid) - { - return TokenValidationResult.Invalid(TokenFormat.Jwt, MapJwtError(result.Exception)); - } +// if (!result.IsValid) +// { +// return TokenValidationResult.Invalid(TokenFormat.Jwt, MapJwtError(result.Exception)); +// } - var jwt = (JsonWebToken)result.SecurityToken; - var claims = jwt.Claims.ToArray(); +// var jwt = (JsonWebToken)result.SecurityToken; +// var claims = jwt.Claims.ToArray(); - var converter = _converters.GetConverter(); +// var converter = _converters.GetConverter(); - var userIdString = jwt.GetClaim(ClaimTypes.NameIdentifier)?.Value ?? jwt.GetClaim("sub")?.Value; - if (string.IsNullOrWhiteSpace(userIdString)) - { - return TokenValidationResult.Invalid(TokenFormat.Jwt, TokenInvalidReason.MissingSubject); - } +// var userIdString = jwt.GetClaim(ClaimTypes.NameIdentifier)?.Value ?? jwt.GetClaim("sub")?.Value; +// if (string.IsNullOrWhiteSpace(userIdString)) +// { +// return TokenValidationResult.Invalid(TokenFormat.Jwt, TokenInvalidReason.MissingSubject); +// } - TUserId userId; - try - { - userId = converter.FromString(userIdString); - } - catch - { - return TokenValidationResult.Invalid(TokenFormat.Jwt, TokenInvalidReason.Malformed); - } +// TUserId userId; +// try +// { +// userId = converter.FromString(userIdString); +// } +// catch +// { +// return TokenValidationResult.Invalid(TokenFormat.Jwt, TokenInvalidReason.Malformed); +// } - var tenantId = jwt.GetClaim("tenant")?.Value ?? jwt.GetClaim("tid")?.Value; - AuthSessionId? sessionId = null; - var sid = jwt.GetClaim("sid")?.Value; - if (AuthSessionId.TryCreate(sid, out AuthSessionId ssid)) - { - sessionId = ssid; - } +// var tenantId = jwt.GetClaim("tenant")?.Value ?? jwt.GetClaim("tid")?.Value; +// AuthSessionId? sessionId = null; +// var sid = jwt.GetClaim("sid")?.Value; +// if (AuthSessionId.TryCreate(sid, out AuthSessionId ssid)) +// { +// sessionId = ssid; +// } - return TokenValidationResult.Valid( - format: TokenFormat.Jwt, - tenant: TenantKey.FromExternal(tenantId), - userId, - sessionId: sessionId, - claims: claims, - expiresAt: jwt.ValidTo); - } +// return TokenValidationResult.Valid( +// format: TokenFormat.Jwt, +// tenant: TenantKey.FromExternal(tenantId), +// userId, +// sessionId: sessionId, +// claims: claims, +// expiresAt: jwt.ValidTo); +// } - private static TokenInvalidReason MapJwtError(Exception? ex) - { - return ex switch - { - SecurityTokenExpiredException => TokenInvalidReason.Expired, - SecurityTokenInvalidSignatureException => TokenInvalidReason.SignatureInvalid, - SecurityTokenInvalidAudienceException => TokenInvalidReason.AudienceMismatch, - SecurityTokenInvalidIssuerException => TokenInvalidReason.IssuerMismatch, - _ => TokenInvalidReason.Invalid - }; - } +// private static TokenInvalidReason MapJwtError(Exception? ex) +// { +// return ex switch +// { +// SecurityTokenExpiredException => TokenInvalidReason.Expired, +// SecurityTokenInvalidSignatureException => TokenInvalidReason.SignatureInvalid, +// SecurityTokenInvalidAudienceException => TokenInvalidReason.AudienceMismatch, +// SecurityTokenInvalidIssuerException => TokenInvalidReason.IssuerMismatch, +// _ => TokenInvalidReason.Invalid +// }; +// } -} +//} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs index a273a589..f7bba806 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs @@ -25,12 +25,13 @@ public static AuthFlowContext New( UserKey? userKey = null, SessionSecurityContext? session = null, bool isAuthenticated = true, - EffectiveAuthResponse? response = null) + EffectiveAuthResponse? response = null, + UAuthMode mode = UAuthMode.PureOpaque) { return new AuthFlowContext( flowType: AuthFlowType.Login, clientProfile: UAuthClientProfile.BlazorServer, - effectiveMode: UAuthMode.PureOpaque, + effectiveMode: mode, device: TestDevice.Default(), tenantKey: tenant ?? TenantKey.Single, isAuthenticated: isAuthenticated, diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/UAuthSessionIssuerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/UAuthSessionIssuerTests.cs new file mode 100644 index 00000000..026ce404 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/UAuthSessionIssuerTests.cs @@ -0,0 +1,1958 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.Extensions.Options; +using Moq; +using System.Security; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class UAuthSessionIssuerTests +{ + private static readonly DateTimeOffset Now = + new(2026, 10, 6, 12, 0, 0, TimeSpan.Zero); + + private static readonly TenantKey Tenant = + TenantKey.FromExternal("tenant-a"); + + private static readonly TimeSpan Lifetime = + TimeSpan.FromHours(8); + + private const string OpaqueToken = + "opaque-session-token-000000000000000000000001"; + + // ===================================================================== + // IssueSessionAsync - mode / token guards + // ===================================================================== + + [Fact] + public async Task IssueSessionAsync_WhenModeIsPureJwt_ShouldRejectSessionIssuance() + { + var fixture = CreateFixture(); + var context = CreateIssuanceContext( + mode: UAuthMode.PureJwt); + + var act = () => + fixture.Sut.IssueSessionAsync(context); + + await act.Should() + .ThrowAsync(); + + fixture.TokenGenerator.Verify( + x => x.Generate(), + Times.Never); + + fixture.StoreFactory.Verify( + x => x.Create(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task IssueSessionAsync_WhenOpaqueGeneratorReturnsInvalidId_ShouldFailBeforeStoreAccess() + { + var fixture = CreateFixture( + opaqueToken: "invalid"); + + var context = CreateIssuanceContext(); + + var act = () => + fixture.Sut.IssueSessionAsync(context); + + await act.Should() + .ThrowAsync(); + + fixture.StoreFactory.Verify( + x => x.Create(It.IsAny()), + Times.Never); + } + + // ===================================================================== + // IssueSessionAsync - root + // ===================================================================== + + [Fact] + public async Task IssueSessionAsync_WhenActiveRootDoesNotExist_ShouldCreateRoot() + { + var fixture = CreateFixture(); + var context = CreateIssuanceContext(); + + fixture.Store + .Setup(x => x.GetActiveRootByUserAsync( + context.UserKey, + It.IsAny())) + .ReturnsAsync((UAuthSessionRoot?)null); + + UAuthSessionRoot? createdRoot = null; + + fixture.Store + .Setup(x => x.CreateRootAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (root, _) => createdRoot = root) + .Returns(Task.CompletedTask); + + SetupEmptySessions(fixture); + SetupSessionAndChainPersistence(fixture); + + await fixture.Sut.IssueSessionAsync(context); + + createdRoot.Should().NotBeNull(); + createdRoot!.Tenant.Should().Be(context.Tenant); + createdRoot.UserKey.Should().Be(context.UserKey); + + fixture.Store.Verify( + x => x.CreateRootAsync( + It.IsAny(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task IssueSessionAsync_WhenActiveRootExists_ShouldReuseRoot() + { + var fixture = CreateFixture(); + var context = CreateIssuanceContext(); + + var root = CreateRoot( + context.Tenant, + context.UserKey); + + fixture.Store + .Setup(x => x.GetActiveRootByUserAsync( + context.UserKey, + It.IsAny())) + .ReturnsAsync(root); + + SetupEmptySessions(fixture); + SetupSessionAndChainPersistence(fixture); + + var result = + await fixture.Sut.IssueSessionAsync(context); + + result.Session.Should().NotBeNull(); + + fixture.Store.Verify( + x => x.CreateRootAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + // ===================================================================== + // IssueSessionAsync - chain creation + // ===================================================================== + + [Fact] + public async Task IssueSessionAsync_WhenChainIdIsNotProvided_ShouldCreateNewChain() + { + var fixture = CreateFixture(); + var context = CreateIssuanceContext(); + + var root = CreateRoot( + context.Tenant, + context.UserKey); + + SetupRoot(fixture, context.UserKey, root); + + UAuthSessionChain? createdChain = null; + + fixture.Store + .Setup(x => x.CreateChainAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (chain, _) => createdChain = chain) + .Returns(Task.CompletedTask); + + SetupEmptySessions(fixture); + SetupSessionPersistence(fixture); + SetupChainSave(fixture); + + var result = + await fixture.Sut.IssueSessionAsync(context); + + createdChain.Should().NotBeNull(); + + createdChain!.Tenant.Should() + .Be(context.Tenant); + + createdChain.UserKey.Should() + .Be(context.UserKey); + + createdChain.RootId.Should() + .Be(root.RootId); + + result.Session.ChainId.Should() + .Be(createdChain.ChainId); + } + + [Fact] + public async Task IssueSessionAsync_WhenExistingChainIsProvided_ShouldReuseChain() + { + var fixture = CreateFixture(); + var context = CreateIssuanceContext(); + + var root = CreateRoot( + context.Tenant, + context.UserKey); + + var chain = CreateChain( + root, + context.UserKey, + context.Tenant); + + context = CreateIssuanceContext( + userKey: context.UserKey, + chainId: chain.ChainId); + + SetupRoot(fixture, context.UserKey, root); + + fixture.Store + .Setup(x => x.GetChainAsync( + chain.ChainId, + It.IsAny())) + .ReturnsAsync(chain); + + SetupEmptySessions(fixture); + SetupSessionPersistence(fixture); + SetupChainSave(fixture); + + var result = + await fixture.Sut.IssueSessionAsync(context); + + result.Session.ChainId.Should() + .Be(chain.ChainId); + + fixture.Store.Verify( + x => x.CreateChainAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task IssueSessionAsync_WhenExplicitChainDoesNotExist_CurrentlyCreatesDifferentChain() + { + var fixture = CreateFixture(); + var userKey = UserKey.New(); + + var requestedChainId = + SessionChainId.New(); + + var context = CreateIssuanceContext( + userKey: userKey, + chainId: requestedChainId); + + var root = CreateRoot( + context.Tenant, + context.UserKey); + + SetupRoot(fixture, context.UserKey, root); + + fixture.Store + .Setup(x => x.GetChainAsync( + requestedChainId, + It.IsAny())) + .ReturnsAsync((UAuthSessionChain?)null); + + UAuthSessionChain? createdChain = null; + + fixture.Store + .Setup(x => x.CreateChainAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (chain, _) => createdChain = chain) + .Returns(Task.CompletedTask); + + SetupEmptySessions(fixture); + SetupSessionPersistence(fixture); + SetupChainSave(fixture); + + var result = + await fixture.Sut.IssueSessionAsync(context); + + createdChain.Should().NotBeNull(); + + // Regression documentation: + // Current implementation silently creates a NEW chain when the + // explicitly requested chain cannot be found. + createdChain!.ChainId.Should() + .NotBe(requestedChainId); + + result.Session.ChainId.Should() + .Be(createdChain.ChainId); + } + + // ===================================================================== + // IssueSessionAsync - chain security + // ===================================================================== + + [Fact] + public async Task IssueSessionAsync_WhenExistingChainBelongsToDifferentUser_ShouldReject() + { + var fixture = CreateFixture(); + + var user = UserKey.New(); + var otherUser = UserKey.New(); + + var root = CreateRoot(Tenant, user); + var chain = CreateChain( + root, + otherUser, + Tenant); + + var context = CreateIssuanceContext( + userKey: user, + chainId: chain.ChainId); + + SetupRoot(fixture, user, root); + SetupChain(fixture, chain); + + var act = () => + fixture.Sut.IssueSessionAsync(context); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task IssueSessionAsync_WhenExistingChainBelongsToDifferentTenant_ShouldReject() + { + var fixture = CreateFixture(); + + var user = UserKey.New(); + var otherTenant = + TenantKey.FromExternal("tenant-b"); + + var root = CreateRoot(Tenant, user); + + var chain = CreateChain( + root, + user, + otherTenant); + + var context = CreateIssuanceContext( + userKey: user, + chainId: chain.ChainId); + + SetupRoot(fixture, user, root); + SetupChain(fixture, chain); + + var act = () => + fixture.Sut.IssueSessionAsync(context); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task IssueSessionAsync_WhenChainBelongsToDifferentRoot_ShouldReject() + { + var fixture = CreateFixture(); + + var user = UserKey.New(); + + var activeRoot = + CreateRoot(Tenant, user); + + var otherRoot = + CreateRoot(Tenant, user); + + var chain = + CreateChain(otherRoot, user, Tenant); + + var context = CreateIssuanceContext( + userKey: user, + chainId: chain.ChainId); + + SetupRoot(fixture, user, activeRoot); + SetupChain(fixture, chain); + + var act = () => + fixture.Sut.IssueSessionAsync(context); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task IssueSessionAsync_WhenChainIsRevoked_ShouldReject() + { + var fixture = CreateFixture(); + + var user = UserKey.New(); + var root = CreateRoot(Tenant, user); + + var chain = CreateChain( + root, + user, + Tenant); + + chain = chain.Revoke(Now); + + var context = CreateIssuanceContext( + userKey: user, + chainId: chain.ChainId); + + SetupRoot(fixture, user, root); + SetupChain(fixture, chain); + + var act = () => + fixture.Sut.IssueSessionAsync(context); + + await act.Should() + .ThrowAsync(); + } + + // ===================================================================== + // IssueSessionAsync - expiration + // ===================================================================== + + [Fact] + public async Task IssueSessionAsync_ShouldUseConfiguredSessionLifetime() + { + var fixture = CreateFixture( + lifetime: TimeSpan.FromHours(4)); + + var context = CreateIssuanceContext(); + + SetupNewIssuance(fixture, context); + + var result = + await fixture.Sut.IssueSessionAsync(context); + + result.Session.ExpiresAt.Should() + .Be(Now.AddHours(4)); + } + + [Fact] + public async Task IssueSessionAsync_WhenMaxLifetimeIsShorter_ShouldCapExpiration() + { + var fixture = CreateFixture( + lifetime: TimeSpan.FromHours(8), + maxLifetime: TimeSpan.FromHours(2)); + + var context = CreateIssuanceContext(); + + SetupNewIssuance(fixture, context); + + var result = + await fixture.Sut.IssueSessionAsync(context); + + result.Session.ExpiresAt.Should() + .Be(Now.AddHours(2)); + } + + [Fact] + public async Task IssueSessionAsync_WhenMaxLifetimeIsLonger_ShouldUseNormalLifetime() + { + var fixture = CreateFixture( + lifetime: TimeSpan.FromHours(4), + maxLifetime: TimeSpan.FromHours(24)); + + var context = CreateIssuanceContext(); + + SetupNewIssuance(fixture, context); + + var result = + await fixture.Sut.IssueSessionAsync(context); + + result.Session.ExpiresAt.Should() + .Be(Now.AddHours(4)); + } + + // ===================================================================== + // IssueSessionAsync - result / persistence + // ===================================================================== + + [Fact] + public async Task IssueSessionAsync_ShouldPersistSessionAndAttachItToChain() + { + var fixture = CreateFixture(); + var context = CreateIssuanceContext(); + + var root = CreateRoot( + context.Tenant, + context.UserKey); + + SetupRoot(fixture, context.UserKey, root); + + UAuthSession? createdSession = null; + UAuthSessionChain? savedChain = null; + + fixture.Store + .Setup(x => x.CreateChainAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + fixture.Store + .Setup(x => x.GetSessionsByChainAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(Array.Empty()); + + fixture.Store + .Setup(x => x.CreateSessionAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (session, _) => createdSession = session) + .Returns(Task.CompletedTask); + + fixture.Store + .Setup(x => x.SaveChainAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (chain, _, _) => savedChain = chain) + .Returns(Task.CompletedTask); + + var result = + await fixture.Sut.IssueSessionAsync(context); + + createdSession.Should().NotBeNull(); + savedChain.Should().NotBeNull(); + + result.Session.SessionId.Should() + .Be(createdSession!.SessionId); + + savedChain!.ActiveSessionId.Should() + .Be(createdSession.SessionId); + + result.OpaqueSessionId.Should() + .Be(OpaqueToken); + } + + [Fact] + public async Task IssueSessionAsync_WhenModeIsSemiHybrid_ShouldMarkResultAsMetadataOnly() + { + var fixture = CreateFixture(); + + var context = CreateIssuanceContext( + mode: UAuthMode.SemiHybrid); + + SetupNewIssuance(fixture, context); + + var result = + await fixture.Sut.IssueSessionAsync(context); + + result.IsMetadataOnly.Should().BeTrue(); + } + + [Fact] + public async Task IssueSessionAsync_WhenModeIsHybrid_ShouldNotMarkResultAsMetadataOnly() + { + var fixture = CreateFixture(); + + var context = CreateIssuanceContext( + mode: UAuthMode.Hybrid); + + SetupNewIssuance(fixture, context); + + var result = + await fixture.Sut.IssueSessionAsync(context); + + result.IsMetadataOnly.Should().BeFalse(); + } + + // ===================================================================== + // IssueSessionAsync - session limit + // ===================================================================== + + [Fact] + public async Task IssueSessionAsync_WhenSessionLimitReached_ShouldRemoveOldNonActiveSessions() + { + var fixture = CreateFixture( + maxSessionsPerChain: 2); + + var context = CreateIssuanceContext(); + + var root = CreateRoot( + context.Tenant, + context.UserKey); + + var chain = CreateChain( + root, + context.UserKey, + context.Tenant); + + var activeSession = CreateSession( + context.UserKey, + chain.ChainId, + Now.AddHours(-1)); + + chain = chain.AttachSession( + activeSession.SessionId, + Now.AddHours(-1)); + + var oldSession1 = CreateSession( + context.UserKey, + chain.ChainId, + Now.AddHours(-5)); + + var oldSession2 = CreateSession( + context.UserKey, + chain.ChainId, + Now.AddHours(-4)); + + context = CreateIssuanceContext( + userKey: context.UserKey, + chainId: chain.ChainId); + + SetupRoot(fixture, context.UserKey, root); + SetupChain(fixture, chain); + + fixture.Store + .Setup(x => x.GetSessionsByChainAsync( + chain.ChainId, + It.IsAny())) + .ReturnsAsync(new[] + { + activeSession, + oldSession2, + oldSession1 + }); + + SetupSessionPersistence(fixture); + SetupChainSave(fixture); + + var removed = new List(); + + fixture.Store + .Setup(x => x.RemoveSessionAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (id, _) => removed.Add(id)) + .Returns(Task.CompletedTask); + + await fixture.Sut.IssueSessionAsync(context); + + removed.Should().HaveCount(2); + + removed.Should() + .Contain(oldSession1.SessionId); + + removed.Should() + .Contain(oldSession2.SessionId); + + removed.Should() + .NotContain(activeSession.SessionId); + } + + [Fact] + public async Task IssueSessionAsync_WhenBelowSessionLimit_ShouldNotRemoveSessions() + { + var fixture = CreateFixture( + maxSessionsPerChain: 3); + + var context = CreateIssuanceContext(); + + SetupNewIssuance(fixture, context); + + await fixture.Sut.IssueSessionAsync(context); + + fixture.Store.Verify( + x => x.RemoveSessionAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + // ===================================================================== + // RotateSessionAsync - token guard + // ===================================================================== + + [Fact] + public async Task RotateSessionAsync_WhenOpaqueGeneratorReturnsInvalidId_ShouldFailBeforeTransaction() + { + var fixture = CreateFixture( + opaqueToken: "invalid"); + + var context = CreateRotationContext(); + + var act = () => + fixture.Sut.RotateSessionAsync(context); + + await act.Should() + .ThrowAsync(); + + fixture.StoreFactory.Verify( + x => x.Create(context.Tenant), + Times.Once); + + fixture.Store.Verify( + x => x.ExecuteAsync( + It.IsAny>(), + It.IsAny()), + Times.Never); + } + + // ===================================================================== + // RotateSessionAsync - security failures + // ===================================================================== + + [Fact] + public async Task RotateSessionAsync_WhenRootDoesNotExist_ShouldThrowSecurityException() + { + var fixture = CreateFixture(); + var context = CreateRotationContext(); + + fixture.Store + .Setup(x => x.GetActiveRootByUserAsync( + context.UserKey, + It.IsAny())) + .ReturnsAsync((UAuthSessionRoot?)null); + + var act = () => + fixture.Sut.RotateSessionAsync(context); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task RotateSessionAsync_WhenSessionDoesNotExist_ShouldThrowSecurityException() + { + var fixture = CreateFixture(); + var context = CreateRotationContext(); + + var root = CreateRoot( + context.Tenant, + context.UserKey); + + SetupRoot(fixture, context.UserKey, root); + + fixture.Store + .Setup(x => x.GetSessionAsync( + context.CurrentSessionId, + It.IsAny())) + .ReturnsAsync((UAuthSession?)null); + + var act = () => + fixture.Sut.RotateSessionAsync(context); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task RotateSessionAsync_WhenSessionIsRevoked_ShouldThrowSecurityException() + { + var fixture = CreateFixture(); + var context = CreateRotationContext(); + + var root = CreateRoot( + context.Tenant, + context.UserKey); + + var chain = CreateChain( + root, + context.UserKey, + context.Tenant); + + var session = CreateSession( + context.UserKey, + chain.ChainId, + Now.AddHours(-1), + context.CurrentSessionId, + root.SecurityVersion); + + session = session.Revoke( + Now.AddMinutes(-1)); + + SetupRoot(fixture, context.UserKey, root); + SetupSession(fixture, session); + + var act = () => + fixture.Sut.RotateSessionAsync(context); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task RotateSessionAsync_WhenSessionIsExpired_ShouldThrowSecurityException() + { + var fixture = CreateFixture(); + var context = CreateRotationContext(); + + var root = CreateRoot( + context.Tenant, + context.UserKey); + + var chain = CreateChain( + root, + context.UserKey, + context.Tenant); + + var session = UAuthSession.Create( + context.CurrentSessionId, + context.Tenant, + context.UserKey, + chain.ChainId, + Now.AddHours(-10), + Now.AddMinutes(-1), + root.SecurityVersion, + context.Device, + ClaimsSnapshot.Empty, + SessionMetadata.Empty); + + SetupRoot(fixture, context.UserKey, root); + SetupSession(fixture, session); + + var act = () => + fixture.Sut.RotateSessionAsync(context); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task RotateSessionAsync_WhenSessionBelongsToDifferentUser_ShouldThrowSecurityException() + { + var fixture = CreateFixture(); + + var context = CreateRotationContext(); + var otherUser = UserKey.New(); + + var root = CreateRoot( + context.Tenant, + context.UserKey); + + var chain = CreateChain( + root, + otherUser, + context.Tenant); + + var session = CreateSession( + otherUser, + chain.ChainId, + Now.AddHours(-1), + context.CurrentSessionId, + root.SecurityVersion); + + SetupRoot(fixture, context.UserKey, root); + SetupSession(fixture, session); + + var act = () => + fixture.Sut.RotateSessionAsync(context); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task RotateSessionAsync_WhenSecurityVersionDoesNotMatch_ShouldThrowSecurityException() + { + var fixture = CreateFixture(); + var context = CreateRotationContext(); + + var root = CreateRoot( + context.Tenant, + context.UserKey); + + var chain = CreateChain( + root, + context.UserKey, + context.Tenant); + + var session = CreateSession( + context.UserKey, + chain.ChainId, + Now.AddHours(-1), + context.CurrentSessionId, + root.SecurityVersion + 1); + + SetupRoot(fixture, context.UserKey, root); + SetupSession(fixture, session); + + var act = () => + fixture.Sut.RotateSessionAsync(context); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task RotateSessionAsync_WhenChainDoesNotExist_ShouldThrowSecurityException() + { + var fixture = CreateFixture(); + var context = CreateRotationContext(); + + var root = CreateRoot( + context.Tenant, + context.UserKey); + + var chainId = + SessionChainId.New(); + + var session = CreateSession( + context.UserKey, + chainId, + Now.AddHours(-1), + context.CurrentSessionId, + root.SecurityVersion); + + SetupRoot(fixture, context.UserKey, root); + SetupSession(fixture, session); + + fixture.Store + .Setup(x => x.GetChainAsync( + chainId, + It.IsAny())) + .ReturnsAsync((UAuthSessionChain?)null); + + var act = () => + fixture.Sut.RotateSessionAsync(context); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task RotateSessionAsync_WhenChainIsRevoked_ShouldThrowSecurityException() + { + var fixture = CreateFixture(); + var context = CreateRotationContext(); + + var root = CreateRoot( + context.Tenant, + context.UserKey); + + var chain = CreateChain( + root, + context.UserKey, + context.Tenant); + + var session = CreateSession( + context.UserKey, + chain.ChainId, + Now.AddHours(-1), + context.CurrentSessionId, + root.SecurityVersion); + + chain = chain.Revoke(Now); + + SetupRoot(fixture, context.UserKey, root); + SetupSession(fixture, session); + SetupChain(fixture, chain); + + var act = () => + fixture.Sut.RotateSessionAsync(context); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task RotateSessionAsync_WhenChainBelongsToDifferentUser_ShouldThrowSecurityException() + { + var fixture = CreateFixture(); + var context = CreateRotationContext(); + + var root = CreateRoot( + context.Tenant, + context.UserKey); + + var otherUser = UserKey.New(); + + var chain = CreateChain( + root, + otherUser, + context.Tenant); + + var session = CreateSession( + context.UserKey, + chain.ChainId, + Now.AddHours(-1), + context.CurrentSessionId, + root.SecurityVersion); + + SetupRoot(fixture, context.UserKey, root); + SetupSession(fixture, session); + SetupChain(fixture, chain); + + var act = () => + fixture.Sut.RotateSessionAsync(context); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task RotateSessionAsync_WhenChainBelongsToDifferentRoot_ShouldThrowSecurityException() + { + var fixture = CreateFixture(); + var context = CreateRotationContext(); + + var activeRoot = CreateRoot( + context.Tenant, + context.UserKey); + + var otherRoot = CreateRoot( + context.Tenant, + context.UserKey); + + var chain = CreateChain( + otherRoot, + context.UserKey, + context.Tenant); + + var session = CreateSession( + context.UserKey, + chain.ChainId, + Now.AddHours(-1), + context.CurrentSessionId, + activeRoot.SecurityVersion); + + SetupRoot( + fixture, + context.UserKey, + activeRoot); + + SetupSession(fixture, session); + SetupChain(fixture, chain); + + var act = () => + fixture.Sut.RotateSessionAsync(context); + + await act.Should() + .ThrowAsync(); + } + + // ===================================================================== + // RotateSessionAsync - success + // ===================================================================== + + [Fact] + public async Task RotateSessionAsync_ShouldCreateNewSessionOnSameChain() + { + var fixture = CreateFixture(); + var context = CreateRotationContext(); + + var setup = + SetupSuccessfulRotation( + fixture, + context); + + UAuthSession? persistedSession = null; + + fixture.Store + .Setup(x => x.CreateSessionAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (session, _) => + persistedSession = session) + .Returns(Task.CompletedTask); + + var result = + await fixture.Sut.RotateSessionAsync(context); + + persistedSession.Should().NotBeNull(); + + persistedSession!.SessionId.Should() + .NotBe(context.CurrentSessionId); + + persistedSession.ChainId.Should() + .Be(setup.Chain.ChainId); + + result.OpaqueSessionId.Should() + .Be(OpaqueToken); + } + + [Fact] + public async Task RotateSessionAsync_ShouldRotateChainActiveSession() + { + var fixture = CreateFixture(); + var context = CreateRotationContext(); + + var setup = + SetupSuccessfulRotation( + fixture, + context); + + UAuthSessionChain? savedChain = null; + + fixture.Store + .Setup(x => x.SaveChainAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (chain, _, _) => savedChain = chain) + .Returns(Task.CompletedTask); + + await fixture.Sut.RotateSessionAsync(context); + + savedChain.Should().NotBeNull(); + + savedChain!.ActiveSessionId.Should() + .NotBe(context.CurrentSessionId); + + savedChain.ActiveSessionId.Should() + .NotBeNull(); + } + + [Fact] + public async Task RotateSessionAsync_ShouldRevokeOldSession() + { + var fixture = CreateFixture(); + var context = CreateRotationContext(); + + var setup = + SetupSuccessfulRotation( + fixture, + context); + + UAuthSession? savedOldSession = null; + long? expectedVersion = null; + + fixture.Store + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (session, version, _) => + { + savedOldSession = session; + expectedVersion = version; + }) + .Returns(Task.CompletedTask); + + await fixture.Sut.RotateSessionAsync(context); + + savedOldSession.Should().NotBeNull(); + savedOldSession!.IsRevoked.Should().BeTrue(); + + expectedVersion.Should() + .Be(setup.OldSession.Version); + } + + [Fact] + public async Task RotateSessionAsync_ShouldReturnSameBoundSessionThatWasPersisted() + { + var fixture = CreateFixture(); + var context = CreateRotationContext(); + + var setup = + SetupSuccessfulRotation( + fixture, + context); + + UAuthSession? persistedSession = null; + + fixture.Store + .Setup(x => x.CreateSessionAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (session, _) => + persistedSession = session) + .Returns(Task.CompletedTask); + + var result = await fixture.Sut.RotateSessionAsync(context); + + persistedSession.Should().NotBeNull(); + persistedSession!.ChainId.Should().Be(setup.Chain.ChainId); + result.Session.ChainId.Should().Be(setup.Chain.ChainId); + result.Session.SessionId.Should().Be(persistedSession.SessionId); + result.Session.ChainId.Should().Be(persistedSession.ChainId); + } + + [Fact] + public async Task RotateSessionAsync_WhenModeIsSemiHybrid_ShouldMarkResultAsMetadataOnly() + { + var fixture = CreateFixture(); + + var context = CreateRotationContext( + mode: UAuthMode.SemiHybrid); + + SetupSuccessfulRotation( + fixture, + context); + + var result = + await fixture.Sut.RotateSessionAsync(context); + + result.IsMetadataOnly.Should().BeTrue(); + } + + [Fact] + public async Task RotateSessionAsync_WhenMaxLifetimeIsShorter_ShouldCapNewSessionExpiration() + { + var fixture = CreateFixture( + lifetime: TimeSpan.FromHours(8), + maxLifetime: TimeSpan.FromHours(2)); + + var context = CreateRotationContext(); + + SetupSuccessfulRotation( + fixture, + context); + + UAuthSession? persisted = null; + + fixture.Store + .Setup(x => x.CreateSessionAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (session, _) => persisted = session) + .Returns(Task.CompletedTask); + + await fixture.Sut.RotateSessionAsync(context); + + persisted.Should().NotBeNull(); + + persisted!.ExpiresAt.Should() + .Be(Now.AddHours(2)); + } + + // ===================================================================== + // RevokeSessionAsync + // ===================================================================== + + [Fact] + public async Task RevokeSessionAsync_ShouldDelegateToTenantStoreAndReturnResult() + { + var fixture = CreateFixture(); + + var sessionId = + AuthSessionId.Parse(OpaqueToken, null); + + fixture.Store + .Setup(x => x.RevokeSessionAsync( + sessionId, + Now, + It.IsAny())) + .ReturnsAsync(true); + + var result = + await fixture.Sut.RevokeSessionAsync( + Tenant, + sessionId, + Now); + + result.Should().BeTrue(); + + fixture.StoreFactory.Verify( + x => x.Create(Tenant), + Times.Once); + } + + // ===================================================================== + // RevokeChainAsync + // ===================================================================== + + [Fact] + public async Task RevokeChainAsync_WhenChainDoesNotExist_ShouldDoNothing() + { + var fixture = CreateFixture(); + var chainId = SessionChainId.New(); + + fixture.Store + .Setup(x => x.GetChainAsync( + chainId, + It.IsAny())) + .ReturnsAsync((UAuthSessionChain?)null); + + await fixture.Sut.RevokeChainAsync( + Tenant, + chainId, + Now); + + fixture.Store.Verify( + x => x.RevokeChainCascadeAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RevokeChainAsync_WhenChainExists_ShouldCascadeRevoke() + { + var fixture = CreateFixture(); + + var user = UserKey.New(); + var root = CreateRoot(Tenant, user); + var chain = CreateChain(root, user, Tenant); + + SetupChain(fixture, chain); + + fixture.Store + .Setup(x => x.RevokeChainCascadeAsync( + chain.ChainId, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + await fixture.Sut.RevokeChainAsync( + Tenant, + chain.ChainId, + Now); + + fixture.Store.Verify( + x => x.RevokeChainCascadeAsync( + chain.ChainId, + Now, + It.IsAny()), + Times.Once); + } + + // ===================================================================== + // RevokeAllChainsAsync + // ===================================================================== + + [Fact] + public async Task RevokeAllChainsAsync_ShouldRevokeEveryUserChain() + { + var fixture = CreateFixture(); + + var user = UserKey.New(); + var root = CreateRoot(Tenant, user); + + var chain1 = CreateChain(root, user, Tenant); + var chain2 = CreateChain(root, user, Tenant); + + fixture.Store + .Setup(x => x.GetChainsByUserAsync( + user, + false, + It.IsAny())) + .ReturnsAsync(new[] { chain1, chain2 }); + + fixture.Store + .Setup(x => x.RevokeChainCascadeAsync( + It.IsAny(), + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + await fixture.Sut.RevokeAllChainsAsync( + Tenant, + user, + exceptChainId: null, + Now); + + fixture.Store.Verify( + x => x.RevokeChainCascadeAsync( + chain1.ChainId, + Now, + It.IsAny()), + Times.Once); + + fixture.Store.Verify( + x => x.RevokeChainCascadeAsync( + chain2.ChainId, + Now, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task RevokeAllChainsAsync_WhenExceptChainProvided_ShouldPreserveThatChain() + { + var fixture = CreateFixture(); + + var user = UserKey.New(); + var root = CreateRoot(Tenant, user); + + var keep = CreateChain(root, user, Tenant); + var revoke = CreateChain(root, user, Tenant); + + fixture.Store + .Setup(x => x.GetChainsByUserAsync( + user, + false, + It.IsAny())) + .ReturnsAsync(new[] { keep, revoke }); + + fixture.Store + .Setup(x => x.RevokeChainCascadeAsync( + revoke.ChainId, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + await fixture.Sut.RevokeAllChainsAsync( + Tenant, + user, + keep.ChainId, + Now); + + fixture.Store.Verify( + x => x.RevokeChainCascadeAsync( + keep.ChainId, + It.IsAny(), + It.IsAny()), + Times.Never); + + fixture.Store.Verify( + x => x.RevokeChainCascadeAsync( + revoke.ChainId, + Now, + It.IsAny()), + Times.Once); + } + + // ===================================================================== + // RevokeRootAsync + // ===================================================================== + + [Fact] + public async Task RevokeRootAsync_ShouldCascadeRevokeRoot() + { + var fixture = CreateFixture(); + var user = UserKey.New(); + + fixture.Store + .Setup(x => x.RevokeRootCascadeAsync( + user, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + await fixture.Sut.RevokeRootAsync( + Tenant, + user, + Now); + + fixture.Store.Verify( + x => x.RevokeRootCascadeAsync( + user, + Now, + It.IsAny()), + Times.Once); + } + + // ===================================================================== + // GetChainIdBySessionAsync + // ===================================================================== + + [Fact] + public async Task GetChainIdBySessionAsync_ShouldReturnStoreResult() + { + var fixture = CreateFixture(); + + var sessionId = + AuthSessionId.Parse(OpaqueToken, null); + + var chainId = + SessionChainId.New(); + + fixture.Store + .Setup(x => x.GetChainIdBySessionAsync( + sessionId, + It.IsAny())) + .ReturnsAsync(chainId); + + var result = + await fixture.Sut.GetChainIdBySessionAsync( + Tenant, + sessionId); + + result.Should().Be(chainId); + } + + // ===================================================================== + // LogoutChainAsync + // ===================================================================== + + [Fact] + public async Task LogoutChainAsync_WhenChainDoesNotExist_ShouldReturnFalse() + { + var fixture = CreateFixture(); + var chainId = SessionChainId.New(); + + fixture.Store + .Setup(x => x.GetChainAsync( + chainId, + It.IsAny())) + .ReturnsAsync((UAuthSessionChain?)null); + + var result = + await fixture.Sut.LogoutChainAsync( + Tenant, + chainId, + Now); + + result.Should().BeFalse(); + + fixture.Store.Verify( + x => x.LogoutChainAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task LogoutChainAsync_WhenChainIsRevoked_ShouldReturnFalse() + { + var fixture = CreateFixture(); + + var user = UserKey.New(); + var root = CreateRoot(Tenant, user); + + var chain = CreateChain( + root, + user, + Tenant) + .Revoke(Now); + + SetupChain(fixture, chain); + + var result = + await fixture.Sut.LogoutChainAsync( + Tenant, + chain.ChainId, + Now); + + result.Should().BeFalse(); + + fixture.Store.Verify( + x => x.LogoutChainAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task LogoutChainAsync_WhenChainIsActive_ShouldLogoutAndReturnTrue() + { + var fixture = CreateFixture(); + + var user = UserKey.New(); + var root = CreateRoot(Tenant, user); + var chain = CreateChain(root, user, Tenant); + + SetupChain(fixture, chain); + + fixture.Store + .Setup(x => x.LogoutChainAsync( + chain.ChainId, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + var result = + await fixture.Sut.LogoutChainAsync( + Tenant, + chain.ChainId, + Now); + + result.Should().BeTrue(); + + fixture.Store.Verify( + x => x.LogoutChainAsync( + chain.ChainId, + Now, + It.IsAny()), + Times.Once); + } + + // ===================================================================== + // Cancellation / transaction + // ===================================================================== + + [Fact] + public async Task IssueSessionAsync_ShouldPassCancellationTokenToTransaction() + { + var fixture = CreateFixture(); + var context = CreateIssuanceContext(); + + SetupNewIssuance(fixture, context); + + using var cts = + new CancellationTokenSource(); + + await fixture.Sut.IssueSessionAsync( + context, + cts.Token); + + fixture.Store.Verify( + x => x.ExecuteAsync( + It.IsAny>(), + cts.Token), + Times.Once); + } + + [Fact] + public async Task RotateSessionAsync_ShouldPassCancellationTokenToTransaction() + { + var fixture = CreateFixture(); + var context = CreateRotationContext(); + + SetupSuccessfulRotation( + fixture, + context); + + using var cts = + new CancellationTokenSource(); + + await fixture.Sut.RotateSessionAsync( + context, + cts.Token); + + fixture.Store.Verify( + x => x.ExecuteAsync( + It.IsAny>(), + cts.Token), + Times.Once); + } + + // ===================================================================== + // Fixture + // ===================================================================== + + private static Fixture CreateFixture( + string opaqueToken = OpaqueToken, + TimeSpan? lifetime = null, + TimeSpan? maxLifetime = null, + int maxSessionsPerChain = 5) + { + var storeFactory = + new Mock( + MockBehavior.Strict); + + var store = + new Mock( + MockBehavior.Loose); + + var tokenGenerator = + new Mock( + MockBehavior.Strict); + + tokenGenerator + .Setup(x => x.Generate()) + .Returns(opaqueToken); + + storeFactory + .Setup(x => x.Create( + It.IsAny())) + .Returns(store.Object); + + store + .Setup(x => x.ExecuteAsync( + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>( + (action, ct) => action(ct)); + + store + .Setup(x => x.ExecuteAsync( + It.IsAny>>(), + It.IsAny())) + .Returns>, CancellationToken>( + (action, ct) => action(ct)); + + store + .Setup(x => x.ExecuteAsync( + It.IsAny>>(), + It.IsAny())) + .Returns>, CancellationToken>( + (action, ct) => action(ct)); + + var options = + new UAuthServerOptions(); + + options.Session.Lifetime = + lifetime ?? Lifetime; + + options.Session.MaxLifetime = + maxLifetime; + + options.Session.MaxSessionsPerChain = + maxSessionsPerChain; + + var sut = + new UAuthSessionIssuer( + storeFactory.Object, + tokenGenerator.Object, + Options.Create(options)); + + return new Fixture( + sut, + storeFactory, + store, + tokenGenerator); + } + + // ===================================================================== + // Setup helpers + // ===================================================================== + + private static void SetupNewIssuance( + Fixture fixture, + SessionIssuanceContext context) + { + var root = + CreateRoot( + context.Tenant, + context.UserKey); + + SetupRoot( + fixture, + context.UserKey, + root); + + SetupEmptySessions(fixture); + SetupSessionAndChainPersistence(fixture); + } + + private static RotationSetup SetupSuccessfulRotation( + Fixture fixture, + SessionRotationContext context) + { + var root = + CreateRoot( + context.Tenant, + context.UserKey); + + var chain = + CreateChain( + root, + context.UserKey, + context.Tenant); + + var oldSession = + CreateSession( + context.UserKey, + chain.ChainId, + Now.AddHours(-1), + context.CurrentSessionId, + root.SecurityVersion); + + SetupRoot( + fixture, + context.UserKey, + root); + + SetupSession( + fixture, + oldSession); + + SetupChain( + fixture, + chain); + + fixture.Store + .Setup(x => x.CreateSessionAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + fixture.Store + .Setup(x => x.SaveChainAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + fixture.Store + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + return new RotationSetup( + root, + chain, + oldSession); + } + + private static void SetupRoot( + Fixture fixture, + UserKey userKey, + UAuthSessionRoot root) + { + fixture.Store + .Setup(x => x.GetActiveRootByUserAsync( + userKey, + It.IsAny())) + .ReturnsAsync(root); + } + + private static void SetupSession( + Fixture fixture, + UAuthSession session) + { + fixture.Store + .Setup(x => x.GetSessionAsync( + session.SessionId, + It.IsAny())) + .ReturnsAsync(session); + } + + private static void SetupChain( + Fixture fixture, + UAuthSessionChain chain) + { + fixture.Store + .Setup(x => x.GetChainAsync( + chain.ChainId, + It.IsAny())) + .ReturnsAsync(chain); + } + + private static void SetupEmptySessions( + Fixture fixture) + { + fixture.Store + .Setup(x => x.GetSessionsByChainAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync( + Array.Empty()); + } + + private static void SetupSessionPersistence( + Fixture fixture) + { + fixture.Store + .Setup(x => x.CreateSessionAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + } + + private static void SetupChainSave( + Fixture fixture) + { + fixture.Store + .Setup(x => x.SaveChainAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + } + + private static void SetupSessionAndChainPersistence( + Fixture fixture) + { + fixture.Store + .Setup(x => x.CreateChainAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + SetupSessionPersistence(fixture); + SetupChainSave(fixture); + } + + // ===================================================================== + // Domain helpers + // ===================================================================== + + private static SessionIssuanceContext CreateIssuanceContext( + UserKey? userKey = null, + SessionChainId? chainId = null, + UAuthMode mode = UAuthMode.Hybrid) + { + return new SessionIssuanceContext + { + Tenant = Tenant, + UserKey = userKey ?? UserKey.New(), + Device = TestDevice.Default(), + Now = Now, + Claims = ClaimsSnapshot.Empty, + Metadata = SessionMetadata.Empty, + Mode = mode, + ChainId = chainId + }; + } + + private static SessionRotationContext CreateRotationContext( + UAuthMode mode = UAuthMode.Hybrid) + { + return new SessionRotationContext + { + Tenant = Tenant, + CurrentSessionId = + AuthSessionId.Parse( + "current-session-000000000000000000000000001", + null), + UserKey = UserKey.New(), + Now = Now, + Device = TestDevice.Default(), + Claims = ClaimsSnapshot.Empty, + Metadata = SessionMetadata.Empty, + Mode = mode + }; + } + + private static UAuthSessionRoot CreateRoot( + TenantKey tenant, + UserKey userKey) + { + return UAuthSessionRoot.Create( + tenant, + userKey, + Now.AddDays(-1)); + } + + private static UAuthSessionChain CreateChain( + UAuthSessionRoot root, + UserKey userKey, + TenantKey tenant) + { + return UAuthSessionChain.Create( + SessionChainId.New(), + root.RootId, + tenant, + userKey, + Now.AddHours(-2), + Now.AddDays(7), + TestDevice.Default(), + ClaimsSnapshot.Empty, + root.SecurityVersion); + } + + private static UAuthSession CreateSession( + UserKey userKey, + SessionChainId chainId, + DateTimeOffset createdAt, + AuthSessionId? sessionId = null, + long securityVersion = 0) + { + return UAuthSession.Create( + sessionId ?? AuthSessionId.Parse(Guid.NewGuid().ToString(), null), + Tenant, + userKey, + chainId, + createdAt, + Now.AddHours(4), + securityVersion, + TestDevice.Default(), + ClaimsSnapshot.Empty, + SessionMetadata.Empty); + } + + private sealed record RotationSetup( + UAuthSessionRoot Root, + UAuthSessionChain Chain, + UAuthSession OldSession); + + private sealed record Fixture( + UAuthSessionIssuer Sut, + Mock StoreFactory, + Mock Store, + Mock TokenGenerator); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs new file mode 100644 index 00000000..e8316942 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs @@ -0,0 +1,905 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class UAuthTokenIssuerTests +{ + private static readonly DateTimeOffset Now = + new(2026, 10, 6, 12, 0, 0, TimeSpan.Zero); + + private static readonly TenantKey Tenant = + TenantKey.FromExternal("tenant-a"); + + private const string OpaqueToken = + "opaque-token-000000000000000000000000001"; + + private const string RefreshToken = + "refresh-token-00000000000000000000000001"; + + private const string RefreshHash = + "hashed-refresh-token"; + + private const string Jwt = + "header.payload.signature"; + + private const string JwtId = + "jwt-id-000000000000000000000000000001"; + + // ===================================================================== + // Access token - PureOpaque + // ===================================================================== + + [Fact] + public async Task IssueAccessTokenAsync_WhenModeIsPureOpaque_ShouldIssueOpaqueToken() + { + var fixture = CreateFixture(); + + var flow = CreateFlow( + UAuthMode.PureOpaque); + + var context = CreateTokenContext(); + + var result = + await fixture.Sut.IssueAccessTokenAsync( + flow, + context); + + result.Format.Should().Be(TokenFormat.Opaque); + result.Token.Should().Be(OpaqueToken); + + fixture.OpaqueGenerator.Verify( + x => x.Generate(), + Times.Once); + + fixture.JwtGenerator.Verify( + x => x.CreateToken( + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task IssueAccessTokenAsync_WhenModeIsHybrid_CurrentlyIssuesOpaqueToken() + { + var fixture = CreateFixture(); + + var flow = CreateFlow( + UAuthMode.Hybrid); + + var context = CreateTokenContext(); + + var result = + await fixture.Sut.IssueAccessTokenAsync( + flow, + context); + + // Characterizes current Hybrid behavior. + // Production code explicitly marks JWT Hybrid as a future decision. + result.Format.Should().Be(TokenFormat.Opaque); + result.Token.Should().Be(OpaqueToken); + + fixture.JwtGenerator.Verify( + x => x.CreateToken( + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task IssueAccessTokenAsync_WhenOpaque_ShouldUseConfiguredLifetime() + { + var fixture = CreateFixture(); + + var flow = CreateFlow( + UAuthMode.PureOpaque, + accessTokenLifetime: TimeSpan.FromMinutes(17)); + + var result = + await fixture.Sut.IssueAccessTokenAsync( + flow, + CreateTokenContext()); + + result.ExpiresAt.Should() + .Be(Now.AddMinutes(17)); + } + + // ===================================================================== + // Access token - JWT modes + // ===================================================================== + + [Theory] + [InlineData(UAuthMode.SemiHybrid)] + [InlineData(UAuthMode.PureJwt)] + public async Task IssueAccessTokenAsync_WhenModeUsesJwt_ShouldIssueJwt( + UAuthMode mode) + { + var fixture = CreateFixture(); + + var flow = CreateFlow(mode); + var context = CreateTokenContext(); + + var result = + await fixture.Sut.IssueAccessTokenAsync( + flow, + context); + + result.Format.Should().Be(TokenFormat.Jwt); + result.Token.Should().Be(Jwt); + + fixture.JwtGenerator.Verify( + x => x.CreateToken( + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task IssueAccessTokenAsync_WhenJwt_ShouldBuildDescriptorFromContextAndOptions() + { + var fixture = CreateFixture(); + + var flow = CreateFlow( + UAuthMode.PureJwt, + accessTokenLifetime: TimeSpan.FromMinutes(25), + issuer: "https://issuer.example", + audience: "ultimate-api", + keyId: "key-1"); + + var context = CreateTokenContext( + claims: new Dictionary + { + ["role"] = "admin", + ["permission"] = "products.read" + }); + + UAuthJwtTokenDescriptor? captured = null; + + fixture.JwtGenerator + .Setup(x => x.CreateToken( + It.IsAny())) + .Callback( + descriptor => captured = descriptor) + .Returns(Jwt); + + var result = + await fixture.Sut.IssueAccessTokenAsync( + flow, + context); + + captured.Should().NotBeNull(); + + captured!.Subject.Should() + .Be(context.UserKey); + + captured.Tenant.Should() + .Be(context.Tenant); + + captured.Issuer.Should() + .Be("https://issuer.example"); + + captured.Audience.Should() + .Be("ultimate-api"); + + captured.KeyId.Should() + .Be("key-1"); + + captured.IssuedAt.Should() + .Be(Now); + + captured.ExpiresAt.Should() + .Be(Now.AddMinutes(25)); + + captured.Claims.Should() + .ContainKey("role") + .WhoseValue.Should() + .Be("admin"); + + captured.Claims.Should() + .ContainKey("permission") + .WhoseValue.Should() + .Be("products.read"); + + result.ExpiresAt.Should() + .Be(captured.ExpiresAt); + } + + [Fact] + public async Task IssueAccessTokenAsync_WhenJwt_ShouldIncludeSubjectAndTenantClaims() + { + var fixture = CreateFixture(); + + var flow = CreateFlow(UAuthMode.PureJwt); + var context = CreateTokenContext(); + + UAuthJwtTokenDescriptor? captured = null; + + fixture.JwtGenerator + .Setup(x => x.CreateToken( + It.IsAny())) + .Callback( + descriptor => captured = descriptor) + .Returns(Jwt); + + await fixture.Sut.IssueAccessTokenAsync( + flow, + context); + + captured.Should().NotBeNull(); + + captured!.Claims.Should() + .ContainKey("sub"); + + captured.Claims!["sub"].Should() + .Be(context.UserKey.Value); + + captured.Claims.Should() + .ContainKey("tenant"); + + captured.Claims["tenant"].Should() + .Be(context.Tenant); + } + + [Fact] + public async Task IssueAccessTokenAsync_WhenJwtAndSessionExists_ShouldIncludeSessionClaim() + { + var fixture = CreateFixture(); + + var sessionId = + CreateSessionId( + "jwt-session-0000000000000000000000000001"); + + var flow = CreateFlow(UAuthMode.SemiHybrid); + + var context = CreateTokenContext( + sessionId: sessionId); + + UAuthJwtTokenDescriptor? captured = null; + + fixture.JwtGenerator + .Setup(x => x.CreateToken( + It.IsAny())) + .Callback( + descriptor => captured = descriptor) + .Returns(Jwt); + + var result = + await fixture.Sut.IssueAccessTokenAsync( + flow, + context); + + captured!.Claims.Should() + .ContainKey("sid"); + + captured.Claims!["sid"].Should() + .Be(sessionId); + + result.SessionId.Should() + .Be(sessionId.ToString()); + } + + [Fact] + public async Task IssueAccessTokenAsync_WhenJwtAndSessionDoesNotExist_ShouldNotIncludeSessionClaim() + { + var fixture = CreateFixture(); + + var flow = CreateFlow(UAuthMode.PureJwt); + + var context = CreateTokenContext( + sessionId: null); + + UAuthJwtTokenDescriptor? captured = null; + + fixture.JwtGenerator + .Setup(x => x.CreateToken( + It.IsAny())) + .Callback( + descriptor => captured = descriptor) + .Returns(Jwt); + + var result = + await fixture.Sut.IssueAccessTokenAsync( + flow, + context); + + captured!.Claims.Should() + .NotContainKey("sid"); + + result.SessionId.Should().BeNull(); + } + + [Fact] + public async Task IssueAccessTokenAsync_WhenJwtIdEnabled_ShouldGenerateJti() + { + var fixture = CreateFixture(); + + var flow = CreateFlow( + UAuthMode.PureJwt, + addJwtIdClaim: true); + + UAuthJwtTokenDescriptor? captured = null; + + fixture.JwtGenerator + .Setup(x => x.CreateToken( + It.IsAny())) + .Callback( + descriptor => captured = descriptor) + .Returns(Jwt); + + await fixture.Sut.IssueAccessTokenAsync( + flow, + CreateTokenContext()); + + fixture.OpaqueGenerator.Verify( + x => x.GenerateJwtId(), + Times.Once); + + captured!.Claims.Should() + .ContainKey("jti"); + + captured.Claims!["jti"].Should() + .Be(JwtId); + } + + [Fact] + public async Task IssueAccessTokenAsync_WhenJwtIdDisabled_ShouldNotGenerateJti() + { + var fixture = CreateFixture(); + + var flow = CreateFlow( + UAuthMode.PureJwt, + addJwtIdClaim: false); + + UAuthJwtTokenDescriptor? captured = null; + + fixture.JwtGenerator + .Setup(x => x.CreateToken( + It.IsAny())) + .Callback( + descriptor => captured = descriptor) + .Returns(Jwt); + + await fixture.Sut.IssueAccessTokenAsync( + flow, + CreateTokenContext()); + + fixture.OpaqueGenerator.Verify( + x => x.GenerateJwtId(), + Times.Never); + + captured!.Claims.Should() + .NotContainKey("jti"); + } + + // ===================================================================== + // Refresh token - guards + // ===================================================================== + + [Fact] + public async Task IssueRefreshTokenAsync_WhenModeIsPureOpaque_ShouldReturnNull() + { + var fixture = CreateFixture(); + + var flow = CreateFlow( + UAuthMode.PureOpaque); + + var result = + await fixture.Sut.IssueRefreshTokenAsync( + flow, + CreateTokenContext(), + RefreshTokenPersistence.Persist); + + result.Should().BeNull(); + + fixture.OpaqueGenerator.Verify( + x => x.Generate(), + Times.Never); + + fixture.Hasher.Verify( + x => x.Hash(It.IsAny()), + Times.Never); + + fixture.StoreFactory.Verify( + x => x.Create(It.IsAny()), + Times.Never); + } + + [Theory] + [InlineData(UAuthMode.Hybrid)] + [InlineData(UAuthMode.SemiHybrid)] + [InlineData(UAuthMode.PureJwt)] + public async Task IssueRefreshTokenAsync_WhenSessionIdIsMissing_ShouldReturnNull( + UAuthMode mode) + { + var fixture = CreateFixture(); + + var flow = CreateFlow(mode); + + var context = CreateTokenContext( + sessionId: null); + + var result = + await fixture.Sut.IssueRefreshTokenAsync( + flow, + context, + RefreshTokenPersistence.Persist); + + result.Should().BeNull(); + + fixture.OpaqueGenerator.Verify( + x => x.Generate(), + Times.Never); + + fixture.Hasher.Verify( + x => x.Hash(It.IsAny()), + Times.Never); + + fixture.StoreFactory.Verify( + x => x.Create(It.IsAny()), + Times.Never); + } + + // ===================================================================== + // Refresh token - generation + // ===================================================================== + + [Fact] + public async Task IssueRefreshTokenAsync_ShouldGenerateAndHashRawToken() + { + var fixture = CreateFixture(); + fixture.OpaqueGenerator.Setup(x => x.Generate()).Returns(RefreshToken); + + var sessionId = CreateSessionId("refresh-session-0000000000000000000000001"); + + var flow = CreateFlow(UAuthMode.Hybrid); + + var context = CreateTokenContext(sessionId: sessionId); + + var result = await fixture.Sut.IssueRefreshTokenAsync(flow, context, RefreshTokenPersistence.DoNotPersist); + + result.Should().NotBeNull(); + result!.Token.Should().Be(RefreshToken); + result.TokenHash.Should().Be(RefreshHash); + fixture.Hasher.Verify(x => x.Hash(RefreshToken), Times.Once); + } + + [Fact] + public async Task IssueRefreshTokenAsync_ShouldUseConfiguredRefreshLifetime() + { + var fixture = CreateFixture(); + + var sessionId = + CreateSessionId( + "refresh-session-0000000000000000000000002"); + + var flow = CreateFlow( + UAuthMode.Hybrid, + refreshTokenLifetime: TimeSpan.FromDays(14)); + + var result = + await fixture.Sut.IssueRefreshTokenAsync( + flow, + CreateTokenContext(sessionId: sessionId), + RefreshTokenPersistence.DoNotPersist); + + result!.ExpiresAt.Should() + .Be(Now.AddDays(14)); + } + + // ===================================================================== + // Refresh token - persistence + // ===================================================================== + + [Fact] + public async Task IssueRefreshTokenAsync_WhenPersistenceIsPersist_ShouldStoreHashedToken() + { + var fixture = CreateFixture(); + + var sessionId = + CreateSessionId( + "refresh-session-0000000000000000000000003"); + + var chainId = + SessionChainId.New(); + + var flow = CreateFlow(UAuthMode.Hybrid); + + var context = CreateTokenContext( + sessionId: sessionId, + chainId: chainId); + + RefreshToken? captured = null; + + fixture.Store + .Setup(x => x.StoreAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (token, _) => captured = token) + .Returns(Task.CompletedTask); + + await fixture.Sut.IssueRefreshTokenAsync( + flow, + context, + RefreshTokenPersistence.Persist); + + captured.Should().NotBeNull(); + + captured!.TokenHash.Should() + .Be(RefreshHash); + + captured.Tenant.Should() + .Be(flow.Tenant); + + captured.UserKey.Should() + .Be(context.UserKey); + + captured.SessionId.Should() + .Be(sessionId); + + captured.ChainId.Should() + .Be(chainId); + + captured.CreatedAt.Should() + .Be(Now); + + captured.ExpiresAt.Should() + .Be(Now.Add( + flow.OriginalOptions.Token.RefreshTokenLifetime)); + + fixture.StoreFactory.Verify( + x => x.Create(flow.Tenant), + Times.Once); + + fixture.Store.Verify( + x => x.StoreAsync( + It.IsAny(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task IssueRefreshTokenAsync_WhenPersistenceIsDoNotPersist_ShouldNotAccessStore() + { + var fixture = CreateFixture(); + + var sessionId = + CreateSessionId( + "refresh-session-0000000000000000000000004"); + + var flow = CreateFlow(UAuthMode.Hybrid); + + var result = + await fixture.Sut.IssueRefreshTokenAsync( + flow, + CreateTokenContext(sessionId: sessionId), + RefreshTokenPersistence.DoNotPersist); + + result.Should().NotBeNull(); + + fixture.StoreFactory.Verify( + x => x.Create(It.IsAny()), + Times.Never); + + fixture.Store.Verify( + x => x.StoreAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task IssueRefreshTokenAsync_WhenPersisting_ShouldExecuteInsideStoreTransaction() + { + var fixture = CreateFixture(); + + var sessionId = + CreateSessionId( + "refresh-session-0000000000000000000000005"); + + var flow = CreateFlow(UAuthMode.Hybrid); + + await fixture.Sut.IssueRefreshTokenAsync( + flow, + CreateTokenContext(sessionId: sessionId), + RefreshTokenPersistence.Persist); + + fixture.Store.Verify( + x => x.ExecuteAsync( + It.IsAny>(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task IssueRefreshTokenAsync_ShouldPassCancellationTokenToStoreTransaction() + { + var fixture = CreateFixture(); + + var sessionId = + CreateSessionId( + "refresh-session-0000000000000000000000006"); + + var flow = CreateFlow(UAuthMode.Hybrid); + + using var cts = + new CancellationTokenSource(); + + await fixture.Sut.IssueRefreshTokenAsync( + flow, + CreateTokenContext(sessionId: sessionId), + RefreshTokenPersistence.Persist, + cts.Token); + + fixture.Store.Verify( + x => x.ExecuteAsync( + It.IsAny>(), + cts.Token), + Times.Once); + } + + [Fact] + public async Task IssueRefreshTokenAsync_ShouldPassTransactionTokenToStoreAsync() + { + var fixture = CreateFixture(); + + var sessionId = + CreateSessionId( + "refresh-session-0000000000000000000000007"); + + var flow = CreateFlow(UAuthMode.Hybrid); + + using var cts = + new CancellationTokenSource(); + + CancellationToken receivedToken = default; + + fixture.Store + .Setup(x => x.StoreAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (_, ct) => receivedToken = ct) + .Returns(Task.CompletedTask); + + await fixture.Sut.IssueRefreshTokenAsync( + flow, + CreateTokenContext(sessionId: sessionId), + RefreshTokenPersistence.Persist, + cts.Token); + + receivedToken.Should() + .Be(cts.Token); + } + + [Fact] + public async Task IssueAccessTokenAsync_WhenCustomClaimsContainReservedClaims_ShouldNotOverrideFrameworkClaims() + { + var fixture = CreateFixture(); + + var flow = CreateFlow(UAuthMode.PureJwt); + + var context = CreateTokenContext( + claims: new Dictionary + { + ["sub"] = "attacker-user", + ["tenant"] = "attacker-tenant", + ["role"] = "admin" + }); + + UAuthJwtTokenDescriptor? captured = null; + + fixture.JwtGenerator + .Setup(x => x.CreateToken( + It.IsAny())) + .Callback( + descriptor => captured = descriptor) + .Returns(Jwt); + + await fixture.Sut.IssueAccessTokenAsync( + flow, + context); + + captured.Should().NotBeNull(); + + // Framework-owned identity claims MUST NOT be overridable + // by caller-provided/custom claims. + captured!.Claims.Should() + .ContainKey("sub"); + + captured.Claims!["sub"].Should() + .Be(context.UserKey.Value); + + captured.Claims.Should() + .ContainKey("tenant"); + + captured.Claims["tenant"].Should() + .Be(context.Tenant); + + // Non-reserved custom claims must still flow through normally. + captured.Claims.Should() + .ContainKey("role"); + + captured.Claims["role"].Should() + .Be("admin"); + } + + // ===================================================================== + // Fixture + // ===================================================================== + + private static Fixture CreateFixture() + { + var opaqueGenerator = + new Mock( + MockBehavior.Loose); + + var jwtGenerator = + new Mock( + MockBehavior.Loose); + + var hasher = + new Mock( + MockBehavior.Strict); + + var storeFactory = + new Mock( + MockBehavior.Strict); + + var store = + new Mock( + MockBehavior.Loose); + + var clock = + new Mock( + MockBehavior.Strict); + + clock + .SetupGet(x => x.UtcNow) + .Returns(Now); + + /* + * Generate() is used for both access and refresh tokens. + * Individual refresh tests override this when necessary. + */ + opaqueGenerator + .Setup(x => x.Generate()) + .Returns(OpaqueToken); + + opaqueGenerator + .Setup(x => x.GenerateJwtId()) + .Returns(JwtId); + + jwtGenerator + .Setup(x => x.CreateToken( + It.IsAny())) + .Returns(Jwt); + + hasher + .Setup(x => x.Hash(It.IsAny())) + .Returns(RefreshHash); + + storeFactory + .Setup(x => x.Create( + It.IsAny())) + .Returns(store.Object); + + store + .Setup(x => x.ExecuteAsync( + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>( + (action, ct) => action(ct)); + + store + .Setup(x => x.StoreAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + var sut = + new UAuthTokenIssuer( + opaqueGenerator.Object, + jwtGenerator.Object, + hasher.Object, + storeFactory.Object, + clock.Object); + + return new Fixture( + sut, + opaqueGenerator, + jwtGenerator, + hasher, + storeFactory, + store, + clock); + } + + // ===================================================================== + // Context helpers + // ===================================================================== + + private static AuthFlowContext CreateFlow( + UAuthMode mode, + TimeSpan? accessTokenLifetime = null, + TimeSpan? refreshTokenLifetime = null, + string? issuer = null, + string? audience = null, + string? keyId = null, + bool addJwtIdClaim = true) + { + /* + * Adapt only this helper to the existing AuthFlowContext builder/helper + * in the test project if AuthFlowContext cannot be initialized directly. + * + * Required state: + * - EffectiveMode = mode + * - Tenant = Tenant + * - OriginalOptions.Token values below + */ + + var flow = AuthFlowTestFactory.New( + mode: mode, + tenant: Tenant); + + flow.OriginalOptions.Token.AccessTokenLifetime = + accessTokenLifetime ?? TimeSpan.FromMinutes(15); + + flow.OriginalOptions.Token.RefreshTokenLifetime = + refreshTokenLifetime ?? TimeSpan.FromDays(30); + + if (issuer is not null) + flow.OriginalOptions.Token.Issuer = issuer; + + if (audience is not null) + flow.OriginalOptions.Token.Audience = audience; + + flow.OriginalOptions.Token.KeyId = keyId; + flow.OriginalOptions.Token.AddJwtIdClaim = addJwtIdClaim; + + return flow; + } + + private static TokenIssuanceContext CreateTokenContext( + AuthSessionId? sessionId = null, + SessionChainId? chainId = null, + IReadOnlyDictionary? claims = null) + { + return new TokenIssuanceContext + { + UserKey = UserKey.New(), + Tenant = Tenant, + SessionId = sessionId, + ChainId = chainId, + Claims = claims ?? + new Dictionary(), + IssuedAt = Now + }; + } + + private static AuthSessionId CreateSessionId( + string value) + { + AuthSessionId.TryCreate( + value, + out var id) + .Should() + .BeTrue(); + + return id; + } + + private sealed record Fixture( + UAuthTokenIssuer Sut, + Mock OpaqueGenerator, + Mock JwtGenerator, + Mock Hasher, + Mock StoreFactory, + Mock Store, + Mock Clock); +} From fa039ac6a1ad6d6923c7ed155cacb2579ac2d752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Tue, 6 Oct 2026 23:47:16 +0300 Subject: [PATCH 15/16] Hub Flow & PKCE Tests --- .../uauthhub.db-shm | Bin 32768 -> 32768 bytes .../uauthhub.db-wal | Bin 902312 -> 721032 bytes .../Endpoints/PkceEndpointHandler.cs | 20 +- .../Flows/Pkce/PkceAuthorizationValidator.cs | 28 +- .../Hub/HubCredentialResolver.cs | 7 +- .../Orchestrator/RevokeChainCommand.cs | 21 - .../Orchestrator/RevokeRootCommand.cs | 21 - .../Orchestrator/RevokeSessionCommand.cs | 13 - .../Orchestrator/RotateSessionCommand.cs | 12 - .../SessionId/CompositeSessionIdResolver.cs | 22 +- .../Services/HubFlowService.cs | 14 +- .../Services/PkceService.cs | 16 +- .../PkceFlowIntegrationTests.cs | 588 ++++++++++++ .../Server/HandleHubTests.cs | 650 +++++++++++++ .../Server/HubCredentialResolverTests.cs | 321 +++++++ .../Server/HubFlowReaderTests.cs | 407 +++++++++ .../Server/HubFlowServiceTests.cs | 688 ++++++++++++++ .../Server/PkceEndpointHandlerTests.cs | 31 +- .../Server/PkceServiceTests.cs | 853 ++++++++++++++++++ .../Server/PkceTests.cs | 36 +- .../Sessions/BearerSessionIdResolverTests.cs | 117 +++ .../CompositeSessionIdResolverTests.cs | 350 +++++++ .../Sessions/CookieSessionIdResolverTests.cs | 112 +++ .../Sessions/HeaderSessionIdResolverTests.cs | 111 +++ .../HttpContextSessionExtensionsTests.cs | 110 +++ .../Sessions/QuerySessionIdResolverTests.cs | 115 +++ .../Sessions/SessionContextAccessorTests.cs | 104 +++ .../Users/UserCreateValidatorTests.cs | 499 ++++++++++ .../Users/UserIdentifierValidationTests.cs | 260 ++++++ .../Users/UserProfileValidatorTests.cs | 39 + 30 files changed, 5431 insertions(+), 134 deletions(-) delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/PkceFlowIntegrationTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HandleHubTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HubCredentialResolverTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HubFlowReaderTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HubFlowServiceTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceServiceTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/BearerSessionIdResolverTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/CompositeSessionIdResolverTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/CookieSessionIdResolverTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/HeaderSessionIdResolverTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/HttpContextSessionExtensionsTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/QuerySessionIdResolverTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionContextAccessorTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserCreateValidatorTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierValidationTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserProfileValidatorTests.cs diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-shm b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-shm index 86e2b19dd355eb82f885b92ef11315ef44fbc91e..c4334748a8233baae426588b88560f8b98583ce4 100644 GIT binary patch delta 445 zcmZo@U}|V!s+V}A%K!pmK+MR%Ag~@t`vS4u8-Cs$Y)4PFTKY`jO^|t>sOtG^2m3#6 zQq=>E0t1k_|B(Pxcw_xhrp;fNx7kc$*}!P9`6II?c7 zFS$Sj{4rMXwK}@?*4Ka;TAHu&@2lhDt&F`*7 delta 600 zcmb7=%PYiD6vxl`-A1utM_vumjPlwbrI<0EH4BeMS)fn|A*B??>@9R_mP(S;Z(-aq z-f!b=JZ6m7PW%D3BF^vrEUc7MpMKx-J>Pq7_vdsfPNy;!_nZY<#S;h+^F&`eQ93^# z>5rXgDDUa6c`?sSRlT-Zvzit6fAh3TmbUlnSN+(YF~4FrP!mBU6GkG1NF~yUzh+T1 zk0xS>1R{}0;{8})h$I!o3(+KO%vZ#6;Z|6f33rGZ4eNb7NESl3Uma|0q-O zPB*VARJ@Yrd!>r^(iDN}Fye!*&+VDW7GRk&vn&rDrOS-4OU4`@tZ^|9tjJ+LJc;RA zx^GV#iGg~Dg8NeTH8>Qu2&-C*_cprlTD&!WDr^gGG)uUf=nesEvgvA}uou>4)6trV zcp$VF8BDMQ8)CQp8qu#iX7Ir#$#mo|ohlxR$*wvDx9Q{@D~2N(624wqu_L__IMzLH pKRxh7*NcOsPj$U8MEXo>mJLDS84ewOM{q~PIE6)!mb$8TOpwQ&Fd diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-wal b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-wal index 932093f3c66bd2e327644c7cab157f372b2027bd..05e590d5a80ef1657ef8909e218f98e2225fb95c 100644 GIT binary patch delta 8848 zcmeI1d0bOh_P{|TB%r=1vWOzPRq(+}-Wn7k$x9Fr!77Uif)y0SDuyjqixfqvTE#V> zMX5`1+)5keqE^KP1-GiBgB7c&9Vuu9Yb#Xik9mPddace3%yfRgem=k9&qKcVo_m&i z&be>bhjiWt!x?&!Ajbr~XK!L6HZlC=%EsjPjvrK?h>tAjNv8#KB2!rItNZBF>FK5S z*JWc)pGP$y;!fZ~{NeD=h(BM_hl_<`hfZmV%Rs04yZzDLhINoy6QovuBK!*C=VoeV z5-P={6e__`jB=J@zEa8;C%mN;CS@ou4D-dX-|pSuf{}mOmy@l}#)M+y#ur<}3&!@! z0o=xBVw;{E)U$KxKTpB@O2=}W^QVBV2dVeO+y=M6{D*T+L=Xn6LEj{LCEMqif# z{a$JRt^bCm;rH~d*^t!LsXo-#b@>y$c#Tlpw{u5*?Bc|uiT58`>c@$Dh;1xj(as}GCdPv{QhXE7IIu2|F1DYph zxi^yb*yxL&KwCHxXBxkP*B z8|%-2cnyaboz2EZc|ZO669BY1uz1_z5e2iN^%{`B7T!R6ciYIi8joH426pfU8S;FA z%<=vVQ1JzfG(tlGt2}WKN#Q5Jn ze=z`L<1n)Fn`0wdb@qg;eAR^9&v+_FE6_L3p~~) zfx1)}d7Atla2>ili$^?Qq&_(6PWdkK0uZl<5!`JzJyd%}$|G;Vh){OKf0nhG7z7-E z5$%r_nWZEhqAri1~-Kf*}# z!4dS}vdUFFg2KqfmD}%FdlnA@m;0MResNIA!L?^2Px6QljD!b8`k&mpw62VViQK^J zE%n+Bkvt)RiGc8wIL+tZEdf{rjMy(b+j2iHJD5js7}@DH$$$E{otJMjY}mQA&38NNNg#}@>v1#7*4ows1OOPJGa|3bD>9bxy9B{V zO)?{J^`0OBDK#+Ce+4orrLg!EkA%R;qd#prTa@?Yb5Ps@BY77S=%j?1n|UOZLqa=? zpr(9f)YQf)y}@NWbEp@<^ZaBx)Z?<+>krp$bhj6IP`d9p9`u*Sq~%&(r2hBqfuH zk9f_yvOY%}+B~D?{aPnio;{gU3AA~z=9@*g3ztnkPlEgc^Vx_iiK}oGp(atBR?#SD zX9+6DXa;%Jg!Mn_@LteM|n|eRq8OG+XAAGRc)5^}Td_VXBf3SbP zy~1#@vhWi1D+a{a=48ABD$3!5)zr-Nk7+qq3~p4x;tTnXxruAfWrF4#u=si8r?%-s zn>K>tm%?j^?pCWgVRUX@@W4i5w%v!o`DYmMo^txY;3Ulwz=#Ap`?wP-wUU)`?pCQ4 zoaLly8db1r8OmZ3LLsNQfWQcY&*RZgEfc^>&w2tP+z*6euCX zxtt|13}pybf)cobQ7|e}Ey0x?IU6(4VsJN|I5$sV`^SlpiQS68Hz)44lh!4lj3) zO{bb#M!19ug_XM*R#D9Gu35NJ(FD)b;VSa2x{#G;gvG#HQAq&A73xeGcJ5!eG3v6wYoH=7H;{rh2D1eP^(q;iBw< z+U@4+iXxoOuT=2SmM_AeUs%^zHo`(EMmi(hHTLU;Tf`W!3$cjk?apA76qn!xcWp78 z-Ebv_Dr5wU(o(q!<9aEX3d1|>b~xIi@9@{{_V=Sux3${B?*G#@IT^AJ-^9GhI{&c0 zaXUG1Pl(xsv4(51ClcFnO~$rgl%X9LWmi1b^FMQ=j{H~3byJ1Q-m9B790H@0xiB== zUhPgM5dHemRS>@b7EiPH{_*JQ?=ArC3RpaT{q{gppBuFxe={uppTtRPkGtnz0lW6V zVogA~G==Iv5>yJ1 z;!|^Gr>q(ma) zUQKWbrF=aDNba+tql_fmAM%8pS&UO%;h$u6ZJ$V!J6?@+J&y{98ygW#%d)T94ppKc z;3#~o#AR7u#f{EM1f*{KIwIG+Va51S#LZh-A(82VR;VG%>vOapTHR zP66Xe9p`+8}(ZP72 zCLwhkXFM&=;?u*`Y#_!)&lr!T_)L?gMa@Zy@S!wXTFnq%v*)J19U==%R)_jg1TJAD zEQPYHoIwc*A?M79$xxc2xj_h*5CkiKlksrc9FyV^vgC=Cve$O z1X=O;1G_JNPV0RT#|WW5&c12QekC(5l_7G=_xPr@s4nh zxqA&$C$vdb-sIDJP@UTFShO9{J$zr3XpEuRr(&GfhbJ5X@mU;V{0w|$+V#=nhM3N< z1XHs_=MbH8i^$K&q|x!W!aP3?0{L4+kC9*BbV+ZV;XZ$p6c;{y;2^H7UGnk46FX)a zg7_OmmwQa)Wn65`1WjJv^*en*#W7gtxOsEfysv*+Z3tq{FeJQON|nBD#d~)v4gZ6V z`SGR&iCY)BCmLQyUm%WhHZ*7~hrRN(c+IM{aa9#2TYz($C?9b&UhqcMV@yR6E_QZ? z30fx)vvk)@-7kWkt&P9??GfDs7fe5XbfLLpI@UcLY#CuZ;#bb9g4jMwG$7o`64H0S zRbl0IkF6dc$p;oc_0R0#bu-~P$o9AVd)9S;%|T^aHLP)qz5@!v;B^v~<@H@T)3G-w zjf2Gwr{!l4{t&O_Q~j0i!=)$P8lEk05P-TxmRU%@*R7)yhgnW}v%_CeNp@)e>AasS zUVzrs@ZL|nb2H7O%|8I*Vpx3U7=A3J@4#ojK6H+^c}1_vo*NhBo?2uA0xB%~yh*Q( zt1YMe8|prvZC|)Aw%G(kUxxL%%D8AN9?aK*lsZ`48+kOUx>8VXcsk2QbOBaE=M6)x G;{OH$wgmwI delta 12958 zcmeHO30PCtwhnUw!T~8@MP(2t;E*#UCkddEQDqPiP!JI)L}rKt1XN_uD%P=5jI~v< z4pmDPi@Md;7H6wgaUSbjt=j9Q&Q_~$9}?oGwfBYIzVG(?-sK}|);|AU>#X&!z1JSB zsq$YlQzRBuSXXoKYuz{;M-Ka=%FB(u(SOC#zA>7F$(JBK`}T<;Do@FWC8{7 zmkgi?hM{TFk01gG;TIOim6zyp4=wgGqOEWQIL-f?+x0*sWB8a{fpJPcW zfM-DPP{hmd_mu_9*D0Db#}LiSGA4YMc*6gX{)O#OK;5>7%ad0F)`tz8V^H9RDu}!tR_*gNwG}jffkM{Bc{sR_ zidk=fyp2NMuPnQ=?}B$6Xg!WX=tnbhBdE-N28cTfnfjol*C~AWec<^3g&cBOkUfku zRBV9sL?N|i)4p19;G7MJayCaI^0=9-8A=}YGC({!KwRfNx7>Sw0H~xLY8H^eWsqg@LkXtgF<@UDZG30!H?}B>5W33Ej{gizS-OiT+yPCksnSDvORR! z&Y-0a3gO=_2`j()WC?Km0EL|F(GpfO=F>95V8q{+z-0$-fQA} zasy-l3elwPysOHI69Uai6f*E1-9A$vt&223{7}gJTy5f|>M#=2KSUv8efK;+m6`n9 z0Krj+<7T&6wHGrYK+9VeNL=p6`rCc5_}d`{2p@%PIc+L zO5a7rm3>}$ODA=^1LoJE3hI6x)9ccL>{f#U5vpL-#7*1hSuGw8nm416nW?k4oZb5K z4Fg1iLI(WukK-d&ZR!eIPoa=+_O3W`KQ?Ns0TP5ljwUV2USiY40MExLWQs2~uK%H$ zTmxhv3URHkuAVpg;&u?_YKcT7cg6QpqRQ`c43NPnYt#HvyTFv`;VPk zV}L|JNJRT27({NgSV`{s8nn1saUHw2i|fDo@S@LY5w~srffWXJzG-<#3pU8d%)+!{ zzy-By_KlQY&Y#qE-6FQqVZFsdTBu?a1TG=OGF(6@Ww=;Qs&GO{2x+BAMXSh#-K_m| zdB0fme35#CCIyt36vB>O(ayOIAJq)ggzW?=rB)ZQU=w;5Ksf7aqZ3NIiM$_Y^<4>w zc<2OUTBbAOENYpf?qF!9)l|%f6p7?=N~*+_go?oh0*1n+axsI;NHHT&$z-%dq12gm zv-Z`!U24_y&6dM@?RBk2RgJF_1)sidu-ugXAvzSbd^7K)`cdzGwV<>HwR}9sX70Q7 zX=PykBCA}?hmp}VMUfJmR8b@@kc(+tN(cq8nqz1}AfRcPgw|Ugy2i>m?Dsx@Q~75I zhq3(YioTlJhEKnq^KyqYNnK)3?!;;rrd?meIJs|{%0ioTkSe{@x}mMZ~g51`^Z z^)Gd2%(uSqQdsrk3K66uO+fd_NY-~Lc&FT^o7>IW^*<1p zZWZTkCnXg&f%;#pH(}`}yqtMIi#Xn&31HZU{V^x*)XoH%1FVs(x_SEJw+0s7UI{8C zsQ50qW8P2e5BviVV|k63uJv2%cw?lRs$L!27(xbs#sm~nxa^jLuq5*~IFN;k=gsXJ z@!o?IZs1Cp^<#S6zLm zIx-5xA4J82^ZTy}e|nh@H0M$Akaa<-J;_$HK+PR%Z>%>XQOT4vEym>nkq9zNA;85- zio!)Qni5DP3Q{Q2nf2iLg}!O{WF%smB1Bp<+c>WXE*I7$Tob0Q&P7$CYE=%aena?l!FsK1!OQ z1q_^hC_+Nu0#YExWpW9dnMztpN(h-m$>?^@;`-_wd~JG$BZM}%tjf{M8Tt&a-P#r- zkP@u(Oqo49H9Jop)v(R!?9Qv~ND19%irH=MGsO^SbBAgCTIAN#Pu%18Zwa#DI%4e% z-;gznR~4TO+`??)?0qP`_{(o|JOj7i1^1)$-K!w|FX#0pK~v%$~``g9VjiaBo3Bj94aI9L!~J(v;4mq zQ-lA^ls$CBu|aFxe0qSPIgp6P`Sh-hWn?wQL$$ zVFEUd1ja=2)+drFw6J0m$&~PRF5}dHeHjB^)Hud(kL2z6XRH}$y?|zg&9ns$*ZAod z0QVgLf1qudewJMD;+$2PSq|y6WIz zH<0Piy<+D>F(OhZR8TlWN-12xFa(@elmfWkk-{2APDl0$3nA@v!8Fxj@b^C8h zlfr*7wdnqCONivZAf@Z@^Pio%Qv94vX=}`mgV}XR>L(6u3kg%0XU!({U!VOI|G`BJ zoBf#KxK1|Z*Puyw)0uY*c(CvaQ<81T+S4H-d>49iR)qO#&?YIMoyGD&f2b|Dc-5<+ddCQXo+mL65Ah>9Bsw@4_Z zkb)~~5e>KW7+MKuZGi%pGYV3`s8k9iTx9?52$2dPiJoEsK`_uIT=+4LijfaBGau}8 z8KvJo(pm1{`WuUpphlawqD7frAY=#nT8pyQSANl-+5lQF@h-xho(SG+NbSMpk@enT zQlR-Bg&a61`TU~4dN#Z52Nx~K%oAw%l)C5g2+Urq*Kt37YJ zLxn1STy}OeUzMvEmk}@J7Y|J!!V;v=dGg_Ocy{FY(mZj*JDJf+X--zy(6VS@c&I>- znl3BMiO3Jl*N!VK9v@#YP{F|EkAfCKmr0=Wgi=`3GZc+WDqs#!}*=xc!aZCMJq7f-EyAV$ecCZm#t4+`ymgc zOo0&NIWXtwHvybqdw*{WQm60^VmkR!-asR!W#i&fb!3$s%wNj8hrQm^h1?axMJ&S} zr8uC}ZS?0``zab9k2?CM!3jkNXArLN~8`_{(iV89PJ(roMSCc75SvmN_yn7bj@PB`l69Cr}kg4#7)U7HJ*oh$&FE2y~Q(BgwrdnhNd z+h&!RF2s)OS=Zk##T&-N3XO}C+|n zAiNja#TO;7%6EEbbJ)29W|}A3roomiy1c5SkD;x@34Gu*dc?bXIcE(-Vqf2wp($!N z&2^sKR)54`&gB`c?+u?aPAE=?fc>^TNn_a4hLe~%NB@`)e58XTE4^-W{3`5rvW_&z z217?;gP|*Fj7c_UU1`RB?MhghlL{3nz!`}M9_xt2Vq8qAXk5flVzE#{GNiE0m41I| z7*59Bb9}gX5oOp`G{uOveX&0iulSD_&A7Es9?VV@zl~Spp3=hobq!k#Z5`ruYU*^g zr(M?)B+I-y1g(3?P47jzi6_|t)f`*O!CJ7T(9H`lYtjlj*^G^$pN%;me)*$-?!o^# zhTr$QDeURPa?G5o$LI##v9@lHZeQ3ttHH>Xh8ZhP_UZc2jE$}Z_B98)jUB_jhKd`p zZcY{!19)-L;-bvt7`0ZLRgkZBHMgL7x}9^)ocS(h7G%fP5d{TBTG&sA`#!0K2i@IY z?&}3lTIXUo2NH%I-MKOqB_QAjXG9b%G30EqAyJ8O3jU#z(XdLRCHl=#aUtb-BB5MK36xSua|Jwnl`4d|Od=HFLV;L76GEAoP?%)J z>$j}X5Bca@{-(-$Qxi$HU4TQI5+teG_#xzo7-^X_yoekcU7%1785SQ}9Ic2bOORwl zij;~}L3WHFHfP`@?{xKqtTgqobnl8l@6I5-{SDCAGL70h5Ps3-6cuQoSd*JtG_Ig9 zuY<5aqb^J>%F54(Dbp6I*|w6b{PcnnEd;z%2>(`B6hdKIW??~|8p^UX()9E~$U7(u z3?#kFCwZskre@{A?~}^?y$cI+)$pH*Nl=}-FfU8bPgYPKpAeNL9Uqs>WT+Hkij0*^ z6pki_X68?bmSyKF@`_`}XQf7ljxWtkkAoEG0YCb5u!FZ1yNZY;^V!n|7-(clkRiOo z6)fBGhVIs5KEJD{Q60y=4$^3recH54Gbc`38;v;J>ZGamRrcIYm}-hCCUNNvgh5E~ z7jdBC&yv7o4tN)*!jUPkNS&XWUj*asot&(N?8;RqCwnLPS9oU@6=?$bd~I5$Ixkfl zke8KKSfDK!R}_#|kjF34@+pE4^9hEZm9EY&$|@@3>)FL;iN$s&P0!29hdhkastaL< z26eBw#y;cgorF*kg+MOrxk093Sh#p&E=47A|qoVVJme9Hrj-Czt4{9X4rQe_{;u)SC@F-VZo-520krj zA?+jS;oWriy|q7->r)`u#F3O8kd0jo_{uB%er;K^zB=&;MB7f4;a0o9s^RMmUdh2$ z!t_{-Str7oO|7p%6rCA{VBa0Cf}QD`var#~ZBXaT4@OOFow==Xbi=SZLvhvN+SuK! zk4N?jp7ww(u3!N$2*oYi@^WC(Bj3Qz4IUk?nB6qzKXBfh+o<=>VGec}dS@3llD6`S z_$FUrBP5nyX^6H?jDWCrSUbEfU>_Z+nmp8X zU${OL9vv<}@4q-WzR!w2Q`-h%ho3nPKSS-e9I~ssz)~m9OC6CG$C1l9Tt*Hr(NGe2 zg-Ruq;{v6E#>I?Gh7%$&BPH0|R3hnXuM2WVt0F_AVp650%A$nq;qu4=ztJ+%&u=(i zIW8@Gq&A=SQ!$}3iKbwnuFBWOv!WBPXG%!hV`I4W4kvvY-g=ja#1gW->2=s(Q-Hn= zdW{W(F+ZKfexd+TtA_m1jFkI;mWNBSO5aj;V`84h7`Un?&; znrLrytI@0esF8Nx_ppn2KlHL88*sF(Y=UFVjELNxdUeKZ81rvsLjbB_Ytk1UEmvJM zdJTIy*j^aTFR`RROD{E&Z=Inq?7qW|W t*#D|wq-IRmE@iD^NMFP7ZAyMZG TryCompleteAsync(HttpContext ctx) }); } - var validation = _validator.Validate( - artifact, - request.CodeVerifier, - new PkceContextSnapshot( - clientProfile: artifact.Context.ClientProfile, - tenant: artifact.Context.Tenant, - redirectUri: artifact.Context.RedirectUri, - device: artifact.Context.Device), - _clock.UtcNow); + var completionContext = new PkceContextSnapshot( + clientProfile: authContext.ClientProfile, + tenant: authContext.Tenant, + redirectUri: request.ReturnUrl, + device: authContext.Device); + + var validation = _validator.Validate(artifact, request.CodeVerifier, completionContext, _clock.UtcNow); if (!validation.Success) { @@ -175,7 +173,9 @@ public async Task CompleteAsync(HttpContext ctx) AuthorizationCode = request.AuthorizationCode!, CodeVerifier = request.CodeVerifier!, Identifier = request.Identifier, - Secret = request.Secret + Secret = request.Secret, + ReturnUrl = request.ReturnUrl, + HubSessionId = request.HubSessionId }, ctx.RequestAborted); diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs index ac788f61..b70bddae 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; using System.Security.Cryptography; using System.Text; @@ -25,17 +26,28 @@ public PkceValidationResult Validate(PkceAuthorizationArtifact artifact, string private static bool IsContextValid(PkceContextSnapshot original, PkceContextSnapshot completion) { - if (!original.ClientProfile.Equals(completion.ClientProfile)) - return false; - + // Tenant is part of the server-side security boundary and must + // remain stable throughout the PKCE transaction. if (!string.Equals(original.Tenant, completion.Tenant, StringComparison.Ordinal)) return false; - if (!string.Equals(original.RedirectUri, completion.RedirectUri, StringComparison.Ordinal)) - return false; + // TODO: Bind the effective client profile rather than the physical + // completion request profile. In Hub flows the artifact may represent + // BlazorWasm while the completion request is executed by UAuthHub. + //if (!original.ClientProfile.Equals(completion.ClientProfile)) + // return false; - if (!Equals(original.Device, completion.Device)) - return false; + // TODO: Add protocol-level redirect/return-url binding once the + // relationship between authorization RedirectUri and Hub ReturnUrl + // is explicitly defined. They are not currently equivalent concepts. + //if (!string.Equals(original.RedirectUri, completion.RedirectUri, StringComparison.Ordinal)) + // return false; + + // TODO: Add logical client-device binding. The physical device context + // of the Hub completion request is not necessarily the device context + // captured from the originating client. + //if (!IsDeviceValid(original.Device, completion.Device)) + // return false; return true; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HubCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HubCredentialResolver.cs index 9dbf2d22..8399ca69 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HubCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HubCredentialResolver.cs @@ -7,10 +7,12 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; internal sealed class HubCredentialResolver : IHubCredentialResolver { private readonly IAuthStore _store; + private readonly IClock _clock; - public HubCredentialResolver(IAuthStore store) + public HubCredentialResolver(IAuthStore store, IClock clock) { _store = store; + _clock = clock; } public async Task ResolveAsync(HubSessionId hubSessionId, CancellationToken ct = default) @@ -20,6 +22,9 @@ public HubCredentialResolver(IAuthStore store) if (artifact is not HubFlowArtifact flow) return null; + if (flow.IsExpired(_clock.UtcNow)) + return null; + if (flow.IsCompleted) return null; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs deleted file mode 100644 index 2432db8a..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs +++ /dev/null @@ -1,21 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure; - -public sealed class RevokeChainCommand : ISessionCommand -{ - public SessionChainId ChainId { get; } - - public RevokeChainCommand(SessionChainId chainId) - { - ChainId = chainId; - } - - public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) - { - await issuer.RevokeChainAsync(context.Tenant, ChainId, context.At, ct); - return Unit.Value; - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs deleted file mode 100644 index ab3b2ce1..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs +++ /dev/null @@ -1,21 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure; - -public sealed class RevokeRootCommand : ISessionCommand -{ - public UserKey UserKey { get; } - - public RevokeRootCommand(UserKey userKey) - { - UserKey = userKey; - } - - public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) - { - await issuer.RevokeRootAsync(context.Tenant, UserKey, context.At, ct); - return Unit.Value; - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs deleted file mode 100644 index a2aa8f20..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs +++ /dev/null @@ -1,13 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure; - -internal sealed record RevokeSessionCommand(AuthSessionId SessionId) : ISessionCommand -{ - public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) - { - return await issuer.RevokeSessionAsync(context.Tenant, SessionId, context.At, ct); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs deleted file mode 100644 index 70fc768f..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs +++ /dev/null @@ -1,12 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure; - -internal sealed record RotateSessionCommand(SessionRotationContext RotationContext) : ISessionCommand -{ - public Task ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) - { - return issuer.RotateSessionAsync(RotationContext, ct); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs index 1a687013..8a72f7d5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs @@ -35,12 +35,20 @@ public CompositeSessionIdResolver(IEnumerable resolvers return null; } - private bool IsEnabled(string name) => name switch + private bool IsEnabled(string name) { - "Bearer" => _options.EnableBearer, - "Header" => _options.EnableHeader, - "Cookie" => _options.EnableCookie, - "Query" => _options.EnableQuery, - _ => false - }; + if (name.Equals("Bearer", StringComparison.OrdinalIgnoreCase)) + return _options.EnableBearer; + + if (name.Equals("Header", StringComparison.OrdinalIgnoreCase)) + return _options.EnableHeader; + + if (name.Equals("Cookie", StringComparison.OrdinalIgnoreCase)) + return _options.EnableCookie; + + if (name.Equals("Query", StringComparison.OrdinalIgnoreCase)) + return _options.EnableQuery; + + return false; + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/HubFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/HubFlowService.cs index 6081eb86..1faefb6b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/HubFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/HubFlowService.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Server.Contracts; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Server.Stores; @@ -13,10 +14,7 @@ internal sealed class HubFlowService : IHubFlowService private readonly IClock _clock; private readonly UAuthServerOptions _options; - public HubFlowService( - IAuthStore authStore, - IClock clock, - IOptions options) + public HubFlowService(IAuthStore authStore, IClock clock, IOptions options) { _authStore = authStore; _clock = clock; @@ -61,7 +59,13 @@ public async Task ContinuePkceAsync(string hubSessionId, string authorizationCod var artifact = await _authStore.GetAsync(key, ct) as HubFlowArtifact; if (artifact is null) - throw new InvalidOperationException("Hub session not found."); + throw new UAuthValidationException("Hub session not found."); + + if (artifact.IsExpired(_clock.UtcNow)) + throw new UAuthValidationException("Hub session expired."); + + if (artifact.IsCompleted) + throw new UAuthValidationException("Hub session already completed."); artifact.Payload.Set("authorization_code", authorizationCode); artifact.Payload.Set("code_verifier", codeVerifier); diff --git a/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs b/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs index c64bff3e..ec17b4c9 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs @@ -78,15 +78,13 @@ public async Task CompleteAsync(AuthFlowContext auth, PkceCo }; } - var validation = _validator.Validate( - artifact, - request.CodeVerifier, - new PkceContextSnapshot( - clientProfile: artifact.Context.ClientProfile, - tenant: artifact.Context.Tenant, - redirectUri: artifact.Context.RedirectUri, - device: artifact.Context.Device), - _clock.UtcNow); + var completionContext = new PkceContextSnapshot( + clientProfile: auth.ClientProfile, + tenant: auth.Tenant, + redirectUri: request.ReturnUrl, + device: auth.Device); + + var validation = _validator.Validate(artifact, request.CodeVerifier, completionContext, _clock.UtcNow); if (!validation.Success) { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/PkceFlowIntegrationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/PkceFlowIntegrationTests.cs new file mode 100644 index 00000000..6781cd5b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/PkceFlowIntegrationTests.cs @@ -0,0 +1,588 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using System.Net; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public sealed class PkceFlowIntegrationTests : IClassFixture +{ + private const string AuthorizeEndpoint = "/auth/pkce/authorize"; + private const string CompleteEndpoint = "/auth/pkce/complete"; + private readonly AuthServerFactory _factory; + + public PkceFlowIntegrationTests(AuthServerFactory factory) + { + _factory = factory; + } + + // --------------------------------------------------------------------- + // Happy path + // --------------------------------------------------------------------- + + [Fact] + public async Task Pkce_WithValidVerifierAndCredentials_ShouldAuthenticate() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + using var client = + CreateClient( + $"pkce-valid-{Guid.NewGuid():N}"); + + var verifier = CreateVerifier(); + var challenge = CreateChallenge(verifier); + + var authorization = + await AuthorizeAsync( + client, + challenge); + + authorization.AuthorizationCode + .Should().NotBeNullOrWhiteSpace(); + + var response = + await CompleteAsync( + client, + authorization.AuthorizationCode, + verifier, + user.Identifier, + user.Secret); + + ((int)response.StatusCode) + .Should().BeLessThan(500); + + response.Headers + .TryGetValues("Set-Cookie", out var cookies) + .Should().BeTrue(); + + cookies.Should().NotBeNullOrEmpty(); + } + + // --------------------------------------------------------------------- + // Consume-once / replay protection + // --------------------------------------------------------------------- + + [Fact] + public async Task Pkce_AfterSuccessfulCompletion_ShouldRejectReplayOfAuthorizationCode() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + using var client = + CreateClient( + $"pkce-replay-{Guid.NewGuid():N}"); + + var verifier = CreateVerifier(); + var challenge = CreateChallenge(verifier); + + var authorization = + await AuthorizeAsync( + client, + challenge); + + var first = + await CompleteAsync( + client, + authorization.AuthorizationCode, + verifier, + user.Identifier, + user.Secret); + + first.Headers + .TryGetValues("Set-Cookie", out var firstCookies) + .Should().BeTrue(); + + firstCookies.Should().NotBeNullOrEmpty(); + + // The authorization code was consumed by the first request. + var replay = + await CompleteAsync( + client, + authorization.AuthorizationCode, + verifier, + user.Identifier, + user.Secret); + + replay.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + ((int)replay.StatusCode) + .Should().BeLessThan(500); + } + + [Fact] + public async Task Pkce_WithInvalidVerifier_ShouldConsumeAuthorizationCode() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + using var client = + CreateClient( + $"pkce-invalid-verifier-{Guid.NewGuid():N}"); + + var correctVerifier = CreateVerifier(); + + var authorization = + await AuthorizeAsync( + client, + CreateChallenge(correctVerifier)); + + // First attempt deliberately uses the wrong proof. + var invalidAttempt = + await CompleteAsync( + client, + authorization.AuthorizationCode, + "definitely-wrong-verifier", + user.Identifier, + user.Secret); + + invalidAttempt.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + // Even the correct verifier must no longer work. + // Consume-once means an attacker cannot probe a code repeatedly. + var retryWithCorrectVerifier = + await CompleteAsync( + client, + authorization.AuthorizationCode, + correctVerifier, + user.Identifier, + user.Secret); + + retryWithCorrectVerifier.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + ((int)retryWithCorrectVerifier.StatusCode) + .Should().BeLessThan(500); + } + + // --------------------------------------------------------------------- + // Expiration + // --------------------------------------------------------------------- + + [Fact] + public async Task Pkce_AfterAuthorizationCodeExpires_ShouldRejectCompletion() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + using var client = + CreateClient( + $"pkce-expired-{Guid.NewGuid():N}"); + + var verifier = CreateVerifier(); + + var authorization = + await AuthorizeAsync( + client, + CreateChallenge(verifier)); + + // Configure this to the actual AuthorizationCodeLifetimeSeconds + // if your test server default differs. + _factory.Clock.Advance( + TimeSpan.FromMinutes(10)); + + var response = + await CompleteAsync( + client, + authorization.AuthorizationCode, + verifier, + user.Identifier, + user.Secret); + + response.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + ((int)response.StatusCode) + .Should().BeLessThan(500); + } + + // --------------------------------------------------------------------- + // Credential boundary + // --------------------------------------------------------------------- + + [Fact] + public async Task Pkce_WithValidProofButInvalidCredentials_ShouldNotAuthenticate() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + using var client = + CreateClient( + $"pkce-bad-password-{Guid.NewGuid():N}"); + + var verifier = CreateVerifier(); + + var authorization = + await AuthorizeAsync( + client, + CreateChallenge(verifier)); + + var response = + await CompleteAsync( + client, + authorization.AuthorizationCode, + verifier, + user.Identifier, + "wrong-password"); + + response.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + ((int)response.StatusCode) + .Should().BeLessThan(500); + } + + [Fact] + public async Task Pkce_WithInvalidProof_ShouldNotAuthenticateEvenWithValidCredentials() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + using var client = + CreateClient( + $"pkce-invalid-proof-{Guid.NewGuid():N}"); + + var verifier = CreateVerifier(); + + var authorization = + await AuthorizeAsync( + client, + CreateChallenge(verifier)); + + var response = + await CompleteAsync( + client, + authorization.AuthorizationCode, + "wrong-verifier", + user.Identifier, + user.Secret); + + response.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + ((int)response.StatusCode) + .Should().BeLessThan(500); + } + + // --------------------------------------------------------------------- + // Context binding regression + // --------------------------------------------------------------------- + + //[Fact] + //public async Task Pkce_WhenCompletionReturnUrlDiffers_ShouldRejectCompletion() + //{ + // _factory.Clock.Reset(); + + // var user = + // await _factory.CreateLoginUserAsync(); + + // using var client = + // CreateClient( + // $"pkce-context-return-{Guid.NewGuid():N}"); + + // var verifier = CreateVerifier(); + + // var authorization = + // await AuthorizeAsync( + // client, + // CreateChallenge(verifier), + // returnUrl: "/original-return"); + + // var response = + // await CompleteAsync( + // client, + // authorization.AuthorizationCode, + // verifier, + // user.Identifier, + // user.Secret, + // returnUrl: "/different-return"); + + // // SECURITY INVARIANT: + // // + // // The PKCE artifact was issued for /original-return. + // // Completion is attempting to use /different-return. + // // + // // This should fail through ContextMismatch. + // response.Headers + // .TryGetValues("Set-Cookie", out _) + // .Should().BeFalse(); + //} + + //[Fact] + //public async Task Pkce_WhenCompletionUsesDifferentDevice_ShouldRejectCompletion() + //{ + // _factory.Clock.Reset(); + + // var user = + // await _factory.CreateLoginUserAsync(); + + // var verifier = CreateVerifier(); + + // using var authorizationClient = + // CreateClient( + // $"pkce-device-a-{Guid.NewGuid():N}"); + + // var authorization = + // await AuthorizeAsync( + // authorizationClient, + // CreateChallenge(verifier)); + + // using var completionClient = + // CreateClient( + // $"pkce-device-b-{Guid.NewGuid():N}"); + + // var response = + // await CompleteAsync( + // completionClient, + // authorization.AuthorizationCode, + // verifier, + // user.Identifier, + // user.Secret); + + // // SECURITY INVARIANT: + // // + // // PKCE authorization was initiated from device A. + // // Device B must not be able to complete the same flow. + // response.Headers + // .TryGetValues("Set-Cookie", out _) + // .Should().BeFalse(); + //} + + [Fact] + public async Task Pkce_WithValidProofAndRepeatedInvalidCredentials_ShouldReachCredentialLockoutPipeline() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + using var client = + CreateClient( + $"pkce-lockout-{Guid.NewGuid():N}"); + + // ------------------------------------------------------------- + // Attempt 1 + // + // Each PKCE authorization code is consume-once, including when + // the subsequent credential authentication fails. + // Therefore every credential attempt needs a fresh PKCE flow. + // ------------------------------------------------------------- + + var verifier1 = CreateVerifier(); + + var authorization1 = + await AuthorizeAsync( + client, + CreateChallenge(verifier1)); + + var firstFailure = + await CompleteAsync( + client, + authorization1.AuthorizationCode, + verifier1, + user.Identifier, + "wrong-password-1"); + + firstFailure.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + ((int)firstFailure.StatusCode) + .Should().BeLessThan(500); + + // ------------------------------------------------------------- + // Attempt 2 + // + // Fresh authorization code + valid PKCE proof, but another + // invalid credential attempt. + // + // This must reach the normal credential pipeline and trigger + // the configured lockout threshold. + // ------------------------------------------------------------- + + var verifier2 = CreateVerifier(); + + var authorization2 = + await AuthorizeAsync( + client, + CreateChallenge(verifier2)); + + var secondFailure = + await CompleteAsync( + client, + authorization2.AuthorizationCode, + verifier2, + user.Identifier, + "wrong-password-2"); + + secondFailure.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + ((int)secondFailure.StatusCode) + .Should().BeLessThan(500); + + // ------------------------------------------------------------- + // Attempt 3 + // + // Credentials are now CORRECT. + // + // A fresh and valid PKCE transaction must still not authenticate + // because the credential/user security state should already be + // locked by the previous two credential failures. + // ------------------------------------------------------------- + + var verifier3 = CreateVerifier(); + + var authorization3 = + await AuthorizeAsync( + client, + CreateChallenge(verifier3)); + + var lockedAttempt = + await CompleteAsync( + client, + authorization3.AuthorizationCode, + verifier3, + user.Identifier, + user.Secret); + + lockedAttempt.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + ((int)lockedAttempt.StatusCode) + .Should().BeLessThan(500); + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + private HttpClient CreateClient( + string deviceId) + { + var client = + _factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + client.DefaultRequestHeaders.Add( + "Origin", + "https://localhost:6130"); + + client.DefaultRequestHeaders.Add( + "X-UDID", + deviceId); + + return client; + } + + private static async Task AuthorizeAsync(HttpClient client, string challenge, string? returnUrl = "/callback") + { + using var content = new FormUrlEncodedContent( + new Dictionary + { + ["code_challenge"] = challenge, + ["challenge_method"] = "S256", + ["redirect_uri"] = returnUrl ?? string.Empty + }); + + var response = + await client.PostAsync( + AuthorizeEndpoint, + content); + + response.StatusCode + .Should().Be(HttpStatusCode.OK); + + var result = + await response.Content + .ReadFromJsonAsync(); + + result.Should().NotBeNull(); + result!.AuthorizationCode + .Should().NotBeNullOrWhiteSpace(); + + return result; + } + + private static Task CompleteAsync(HttpClient client, string authorizationCode, string verifier, + string identifier, string secret, string? returnUrl = "/callback") + { + var values = + new Dictionary + { + ["authorization_code"] = + authorizationCode, + + ["code_verifier"] = + verifier, + + ["Identifier"] = + identifier, + + ["Secret"] = + secret, + + [UAuthConstants.Form.ReturnUrl] = + returnUrl ?? string.Empty + }; + + return client.PostAsync( + CompleteEndpoint, + new FormUrlEncodedContent(values)); + } + + private static string CreateVerifier() + { + return Convert + .ToBase64String( + RandomNumberGenerator.GetBytes(32)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + private static string CreateChallenge( + string verifier) + { + var hash = + SHA256.HashData( + Encoding.ASCII.GetBytes(verifier)); + + return Convert + .ToBase64String(hash) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HandleHubTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HandleHubTests.cs new file mode 100644 index 00000000..e2fb0182 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HandleHubTests.cs @@ -0,0 +1,650 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Stores; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class HandleHubTests +{ + private static readonly DateTimeOffset Now = + new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); + + private static readonly TimeSpan FlowLifetime = + TimeSpan.FromMinutes(10); + + private const string AuthorizationCode = "authorization-code"; + private const string CodeVerifier = "code-verifier"; + private const string DeviceIds = "device-id-123456789012345678901234567890"; + private const string ReturnUrl = "/dashboard"; + + // --------------------------------------------------------------------- + // Form requirement + // --------------------------------------------------------------------- + + [Fact] + public async Task HandleHubEntry_WhenFormContentIsMissing_ShouldReturnBadRequest() + { + var fixture = CreateFixture(); + + fixture.Context.Request.Method = HttpMethods.Post; + + var result = await HandleHub.HandleHubEntry( + fixture.Context, + fixture.Store.Object, + fixture.Clock.Object, + fixture.Options); + + result.Should().NotBeNull(); + + fixture.Store.Verify( + x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + // --------------------------------------------------------------------- + // Artifact creation + // --------------------------------------------------------------------- + + [Fact] + public async Task HandleHubEntry_ShouldCreateHubLoginArtifact() + { + var fixture = CreateFixture(); + + SetForm( + fixture.Context, + CreateForm()); + + AuthArtifactKey? capturedKey = null; + AuthArtifact? capturedArtifact = null; + + fixture.Store + .Setup(x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (key, artifact, _) => + { + capturedKey = key; + capturedArtifact = artifact; + }) + .Returns(Task.CompletedTask); + + await HandleHub.HandleHubEntry( + fixture.Context, + fixture.Store.Object, + fixture.Clock.Object, + fixture.Options); + + capturedKey.Should().NotBeNull(); + capturedArtifact.Should().BeOfType(); + + var flow = (HubFlowArtifact)capturedArtifact!; + + capturedKey!.Value.Should().Be( + flow.HubSessionId.Value); + + flow.FlowType.Should().Be( + HubFlowType.Login); + + flow.ClientProfile.Should().Be( + UAuthClientProfile.BlazorWasm); + + flow.ReturnUrl.Should().Be(ReturnUrl); + + flow.IsCompleted.Should().BeFalse(); + flow.AttemptCount.Should().Be(0); + } + + [Fact] + public async Task HandleHubEntry_ShouldStorePkceCredentialsInPayload() + { + var fixture = CreateFixture(); + + SetForm( + fixture.Context, + CreateForm()); + + HubFlowArtifact? captured = null; + + fixture.Store + .Setup(x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (_, artifact, _) => + { + captured = artifact as HubFlowArtifact; + }) + .Returns(Task.CompletedTask); + + await HandleHub.HandleHubEntry( + fixture.Context, + fixture.Store.Object, + fixture.Clock.Object, + fixture.Options); + + captured.Should().NotBeNull(); + + captured!.Payload + .GetRequired("authorization_code") + .Should().Be(AuthorizationCode); + + captured.Payload + .GetRequired("code_verifier") + .Should().Be(CodeVerifier); + } + + [Fact] + public async Task HandleHubEntry_ShouldSetExpirationFromClockAndConfiguredLifetime() + { + var fixture = CreateFixture(); + + SetForm( + fixture.Context, + CreateForm()); + + HubFlowArtifact? captured = null; + + fixture.Store + .Setup(x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (_, artifact, _) => + { + captured = artifact as HubFlowArtifact; + }) + .Returns(Task.CompletedTask); + + await HandleHub.HandleHubEntry( + fixture.Context, + fixture.Store.Object, + fixture.Clock.Object, + fixture.Options); + + captured.Should().NotBeNull(); + + captured!.ExpiresAt.Should().Be( + Now.Add(FlowLifetime)); + } + + // --------------------------------------------------------------------- + // Client profile + // --------------------------------------------------------------------- + + [Fact] + public async Task HandleHubEntry_ShouldParseClientProfileCaseInsensitively() + { + var fixture = CreateFixture(); + + var form = CreateForm(); + + form["__uauth_client_profile"] = + "blazorwasm"; + + SetForm(fixture.Context, form); + + var artifact = await ExecuteAndCaptureArtifact(fixture); + + artifact.ClientProfile.Should().Be( + UAuthClientProfile.BlazorWasm); + } + + [Fact] + public async Task HandleHubEntry_WhenClientProfileIsInvalid_ShouldUseNotSpecified() + { + var fixture = CreateFixture(); + + var form = CreateForm(); + + form["__uauth_client_profile"] = + "definitely-not-a-client-profile"; + + SetForm(fixture.Context, form); + + var artifact = await ExecuteAndCaptureArtifact(fixture); + + artifact.ClientProfile.Should().Be( + UAuthClientProfile.NotSpecified); + } + + [Fact] + public async Task HandleHubEntry_WhenClientProfileIsMissing_ShouldUseNotSpecified() + { + var fixture = CreateFixture(); + + var form = CreateForm(); + + form.Remove("__uauth_client_profile"); + + SetForm(fixture.Context, form); + + var artifact = await ExecuteAndCaptureArtifact(fixture); + + artifact.ClientProfile.Should().Be( + UAuthClientProfile.NotSpecified); + } + + // --------------------------------------------------------------------- + // Device + // --------------------------------------------------------------------- + + [Fact] + public async Task HandleHubEntry_WhenDeviceIsMissing_ShouldUseAnonymousDevice() + { + var fixture = CreateFixture(); + + var form = CreateForm(); + + form.Remove("device"); + + SetForm(fixture.Context, form); + + var artifact = await ExecuteAndCaptureArtifact(fixture); + + AssertAnonymousDevice(artifact.Device); + } + + [Fact] + public async Task HandleHubEntry_WhenDeviceIsEmpty_ShouldUseAnonymousDevice() + { + var fixture = CreateFixture(); + + var form = CreateForm(); + + form["device"] = " "; + + SetForm(fixture.Context, form); + + var artifact = await ExecuteAndCaptureArtifact(fixture); + + AssertAnonymousDevice(artifact.Device); + } + + [Fact] + public async Task HandleHubEntry_WhenDeviceIsInvalidBase64_ShouldUseAnonymousDevice() + { + var fixture = CreateFixture(); + + var form = CreateForm(); + + form["device"] = "%%%not-valid-base64%%%"; + + SetForm(fixture.Context, form); + + var artifact = await ExecuteAndCaptureArtifact(fixture); + + AssertAnonymousDevice(artifact.Device); + } + + [Fact] + public async Task HandleHubEntry_WhenDeviceJsonIsInvalid_ShouldUseAnonymousDevice() + { + var fixture = CreateFixture(); + + var form = CreateForm(); + + var invalidJson = + Microsoft.AspNetCore.WebUtilities.WebEncoders + .Base64UrlEncode( + System.Text.Encoding.UTF8.GetBytes( + "{ definitely-invalid-json")); + + form["device"] = invalidJson; + + SetForm(fixture.Context, form); + + var artifact = await ExecuteAndCaptureArtifact(fixture); + + AssertAnonymousDevice(artifact.Device); + } + + // --------------------------------------------------------------------- + // Return URL + // --------------------------------------------------------------------- + + [Fact] + public async Task HandleHubEntry_ShouldPreserveReturnUrl() + { + var fixture = CreateFixture(); + + var form = CreateForm(); + + form[UAuthConstants.Form.ReturnUrl] = + "/orders/42?tab=details"; + + SetForm(fixture.Context, form); + + var artifact = await ExecuteAndCaptureArtifact(fixture); + + artifact.ReturnUrl.Should().Be( + "/orders/42?tab=details"); + } + + // --------------------------------------------------------------------- + // Store + // --------------------------------------------------------------------- + + [Fact] + public async Task HandleHubEntry_ShouldStoreArtifactUsingGeneratedHubSessionId() + { + var fixture = CreateFixture(); + + SetForm( + fixture.Context, + CreateForm()); + + AuthArtifactKey? capturedKey = null; + HubFlowArtifact? capturedArtifact = null; + + fixture.Store + .Setup(x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (key, artifact, _) => + { + capturedKey = key; + capturedArtifact = + artifact as HubFlowArtifact; + }) + .Returns(Task.CompletedTask); + + await HandleHub.HandleHubEntry( + fixture.Context, + fixture.Store.Object, + fixture.Clock.Object, + fixture.Options); + + capturedKey.Should().NotBeNull(); + capturedArtifact.Should().NotBeNull(); + + capturedKey!.Value.Should().Be( + capturedArtifact!.HubSessionId.Value); + } + + // --------------------------------------------------------------------- + // Redirect + // --------------------------------------------------------------------- + + [Fact] + public async Task HandleHubEntry_ShouldReturnRedirectToConfiguredLoginPath() + { + var fixture = CreateFixture(); + + SetForm( + fixture.Context, + CreateForm()); + + var result = await HandleHub.HandleHubEntry( + fixture.Context, + fixture.Store.Object, + fixture.Clock.Object, + fixture.Options); + + var httpResult = + result.Should() + .BeAssignableTo() + .Subject; + + fixture.Store.Verify( + x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Once); + + // Execute the IResult so we can inspect the real HTTP boundary. + fixture.Context.Response.Body = + new MemoryStream(); + + await httpResult.ExecuteAsync( + fixture.Context); + + fixture.Context.Response.StatusCode + .Should().Be(StatusCodes.Status302Found); + + fixture.Context.Response.Headers.Location + .ToString() + .Should() + .StartWith( + fixture.Options.Value.Hub.LoginPath); + + fixture.Context.Response.Headers.Location + .ToString() + .Should() + .Contain($"{UAuthConstants.Query.Hub}="); + } + + [Fact] + public async Task HandleHubEntry_RedirectHubId_ShouldMatchStoredArtifact() + { + var fixture = CreateFixture(); + + SetForm( + fixture.Context, + CreateForm()); + + HubFlowArtifact? capturedArtifact = null; + + fixture.Store + .Setup(x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (_, artifact, _) => + { + capturedArtifact = + artifact as HubFlowArtifact; + }) + .Returns(Task.CompletedTask); + + var result = await HandleHub.HandleHubEntry( + fixture.Context, + fixture.Store.Object, + fixture.Clock.Object, + fixture.Options); + + capturedArtifact.Should().NotBeNull(); + + fixture.Context.Response.Body = + new MemoryStream(); + + await result.ExecuteAsync( + fixture.Context); + + var location = + fixture.Context.Response.Headers.Location + .ToString(); + + location.Should().Contain( + $"{UAuthConstants.Query.Hub}=" + + $"{capturedArtifact!.HubSessionId.Value}"); + } + + [Fact] + public async Task HandleHubEntry_WhenDeviceIsValid_ShouldPreserveDeviceContext() + { + var fixture = CreateFixture(); + + var deviceId = DeviceId.Create(DeviceIds); + + var device = DeviceContext.Create( + deviceId, + deviceType: "Desktop", + platform: "Web", + operatingSystem: "Windows", + browser: "Edge", + ipAddress: "127.0.0.1"); + + var json = + System.Text.Json.JsonSerializer.Serialize(device); + + var encoded = + Microsoft.AspNetCore.WebUtilities.WebEncoders + .Base64UrlEncode( + System.Text.Encoding.UTF8.GetBytes(json)); + + var form = CreateForm(); + form["device"] = encoded; + + SetForm(fixture.Context, form); + + var artifact = + await ExecuteAndCaptureArtifact(fixture); + + artifact.Device.DeviceId.Should().Be(deviceId); + artifact.Device.HasDeviceId.Should().BeTrue(); + + artifact.Device.DeviceType.Should().Be("desktop"); + artifact.Device.Platform.Should().Be("web"); + artifact.Device.OperatingSystem.Should().Be("windows"); + artifact.Device.Browser.Should().Be("edge"); + artifact.Device.IpAddress.Should().Be("127.0.0.1"); + } + + // --------------------------------------------------------------------- + // Fixture + // --------------------------------------------------------------------- + + private static Fixture CreateFixture() + { + var store = new Mock(); + + store + .Setup(x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + var clock = new Mock(); + + clock + .SetupGet(x => x.UtcNow) + .Returns(Now); + + var options = new UAuthServerOptions(); + + options.Hub.FlowLifetime = FlowLifetime; + options.Hub.LoginPath = "/auth/login"; + + var context = new DefaultHttpContext(); + + context.Items[UAuthConstants.HttpItems.TenantContextKey] = UAuthTenantContext.SingleTenant(); + + // Required for executing Results.Redirect(). + context.RequestServices = + new Microsoft.Extensions.DependencyInjection + .ServiceCollection() + .AddLogging() + .BuildServiceProvider(); + + return new Fixture( + context, + store, + clock, + Options.Create(options)); + } + + private static Dictionary CreateForm() + { + return new Dictionary + { + ["authorization_code"] = + AuthorizationCode, + + ["code_verifier"] = + CodeVerifier, + + ["device_id"] = + DeviceIds, + + [UAuthConstants.Form.ReturnUrl] = + ReturnUrl, + + ["__uauth_client_profile"] = + UAuthClientProfile.BlazorWasm.ToString() + }; + } + + private static void SetForm( + HttpContext context, + Dictionary values) + { + context.Request.Method = HttpMethods.Post; + context.Request.ContentType = + "application/x-www-form-urlencoded"; + + context.Request.Form = + new FormCollection(values); + } + + private static async Task ExecuteAndCaptureArtifact(Fixture fixture) + { + HubFlowArtifact? captured = null; + + fixture.Store + .Setup(x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (_, artifact, _) => + { + captured = + artifact as HubFlowArtifact; + }) + .Returns(Task.CompletedTask); + + await HandleHub.HandleHubEntry( + fixture.Context, + fixture.Store.Object, + fixture.Clock.Object, + fixture.Options); + + captured.Should().NotBeNull(); + + return captured!; + } + + private static void AssertAnonymousDevice( + DeviceContext device) + { + device.Should().NotBeNull(); + + device.DeviceId.Should().BeNull(); + device.HasDeviceId.Should().BeFalse(); + + device.DeviceType.Should().BeNull(); + device.Platform.Should().BeNull(); + device.OperatingSystem.Should().BeNull(); + device.Browser.Should().BeNull(); + device.IpAddress.Should().BeNull(); + } + + private sealed record Fixture( + DefaultHttpContext Context, + Mock Store, + Mock Clock, + IOptions Options); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HubCredentialResolverTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HubCredentialResolverTests.cs new file mode 100644 index 00000000..94507805 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HubCredentialResolverTests.cs @@ -0,0 +1,321 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Stores; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class HubCredentialResolverTests +{ + private const string AuthorizationCode = "authorization-code"; + private const string CodeVerifier = "code-verifier"; + + [Fact] + public async Task ResolveAsync_WhenArtifactDoesNotExist_ShouldReturnNull() + { + var fixture = CreateFixture(); + var hubSessionId = HubSessionId.New(); + + fixture.Store + .Setup(x => x.GetAsync( + new AuthArtifactKey(hubSessionId.Value), + It.IsAny())) + .ReturnsAsync((AuthArtifact?)null); + + var result = await fixture.Sut.ResolveAsync(hubSessionId); + + result.Should().BeNull(); + } + + [Fact] + public async Task ResolveAsync_WhenArtifactIsNotHubFlow_ShouldReturnNull() + { + var fixture = CreateFixture(); + var hubSessionId = HubSessionId.New(); + + var artifact = new TestAuthArtifact( + DateTimeOffset.UtcNow.AddMinutes(5)); + + fixture.Store + .Setup(x => x.GetAsync( + new AuthArtifactKey(hubSessionId.Value), + It.IsAny())) + .ReturnsAsync(artifact); + + var result = await fixture.Sut.ResolveAsync(hubSessionId); + + result.Should().BeNull(); + } + + [Fact] + public async Task ResolveAsync_WhenFlowIsCompleted_ShouldReturnNull() + { + var fixture = CreateFixture(); + var artifact = CreateHubFlowArtifact(); + + artifact.MarkCompleted(); + + SetupArtifact(fixture, artifact); + + var result = await fixture.Sut.ResolveAsync( + artifact.HubSessionId); + + result.Should().BeNull(); + } + + [Fact] + public async Task ResolveAsync_WhenAuthorizationCodeIsMissing_ShouldReturnNull() + { + var fixture = CreateFixture(); + + var payload = new HubFlowPayload(); + payload.Set("code_verifier", CodeVerifier); + + var artifact = CreateHubFlowArtifact(payload); + + SetupArtifact(fixture, artifact); + + var result = await fixture.Sut.ResolveAsync( + artifact.HubSessionId); + + result.Should().BeNull(); + } + + [Fact] + public async Task ResolveAsync_WhenAuthorizationCodeIsEmpty_ShouldReturnNull() + { + var fixture = CreateFixture(); + + var payload = new HubFlowPayload(); + payload.Set("authorization_code", " "); + payload.Set("code_verifier", CodeVerifier); + + var artifact = CreateHubFlowArtifact(payload); + + SetupArtifact(fixture, artifact); + + var result = await fixture.Sut.ResolveAsync( + artifact.HubSessionId); + + result.Should().BeNull(); + } + + [Fact] + public async Task ResolveAsync_WhenCodeVerifierIsMissing_ShouldReturnNull() + { + var fixture = CreateFixture(); + + var payload = new HubFlowPayload(); + payload.Set("authorization_code", AuthorizationCode); + + var artifact = CreateHubFlowArtifact(payload); + + SetupArtifact(fixture, artifact); + + var result = await fixture.Sut.ResolveAsync( + artifact.HubSessionId); + + result.Should().BeNull(); + } + + [Fact] + public async Task ResolveAsync_WhenCodeVerifierIsEmpty_ShouldReturnNull() + { + var fixture = CreateFixture(); + + var payload = new HubFlowPayload(); + payload.Set("authorization_code", AuthorizationCode); + payload.Set("code_verifier", " "); + + var artifact = CreateHubFlowArtifact(payload); + + SetupArtifact(fixture, artifact); + + var result = await fixture.Sut.ResolveAsync( + artifact.HubSessionId); + + result.Should().BeNull(); + } + + [Fact] + public async Task ResolveAsync_WhenCredentialsAreValid_ShouldReturnCredentials() + { + var fixture = CreateFixture(); + + var artifact = CreateHubFlowArtifact( + clientProfile: UAuthClientProfile.BlazorWasm); + + SetupArtifact(fixture, artifact); + + var result = await fixture.Sut.ResolveAsync( + artifact.HubSessionId); + + result.Should().NotBeNull(); + + result!.AuthorizationCode.Should().Be(AuthorizationCode); + result.CodeVerifier.Should().Be(CodeVerifier); + result.ClientProfile.Should().Be(UAuthClientProfile.BlazorWasm); + } + + [Fact] + public async Task ResolveAsync_ShouldReadArtifactUsingHubSessionId() + { + var fixture = CreateFixture(); + var artifact = CreateHubFlowArtifact(); + + SetupArtifact(fixture, artifact); + + await fixture.Sut.ResolveAsync( + artifact.HubSessionId); + + fixture.Store.Verify( + x => x.GetAsync( + It.Is(key => + key.Value == artifact.HubSessionId.Value), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ResolveAsync_ShouldPropagateCancellationToken() + { + var fixture = CreateFixture(); + var artifact = CreateHubFlowArtifact(); + + using var cts = new CancellationTokenSource(); + + fixture.Store + .Setup(x => x.GetAsync( + It.IsAny(), + cts.Token)) + .ReturnsAsync(artifact); + + await fixture.Sut.ResolveAsync( + artifact.HubSessionId, + cts.Token); + + fixture.Store.Verify( + x => x.GetAsync( + It.Is(key => + key.Value == artifact.HubSessionId.Value), + cts.Token), + Times.Once); + } + + [Fact] + public async Task ResolveAsync_WhenFlowIsExpired_ShouldReturnNull() + { + var fixture = CreateFixture(); + + var artifact = CreateHubFlowArtifact( + expiresAt: Now.AddMinutes(-1)); + + SetupArtifact(fixture, artifact); + + var result = await fixture.Sut.ResolveAsync( + artifact.HubSessionId); + + result.Should().BeNull(); + } + + [Fact] + public async Task ResolveAsync_WhenFlowExpiresExactlyNow_ShouldReturnNull() + { + var fixture = CreateFixture(); + + var artifact = CreateHubFlowArtifact( + expiresAt: Now); + + SetupArtifact(fixture, artifact); + + var result = await fixture.Sut.ResolveAsync( + artifact.HubSessionId); + + result.Should().BeNull(); + } + + private static void SetupArtifact( + Fixture fixture, + HubFlowArtifact artifact) + { + fixture.Store + .Setup(x => x.GetAsync( + It.Is(key => + key.Value == artifact.HubSessionId.Value), + It.IsAny())) + .ReturnsAsync(artifact); + } + + private static HubFlowArtifact CreateHubFlowArtifact( + HubFlowPayload? payload = null, + UAuthClientProfile clientProfile = UAuthClientProfile.BlazorWasm, + DateTimeOffset? expiresAt = null) + { + payload ??= CreateValidPayload(); + + return new HubFlowArtifact( + HubSessionId.New(), + HubFlowType.Login, + clientProfile, + TenantKey.Single, + TestDevice.Default(), + returnUrl: "/dashboard", + payload, + expiresAt ?? Now.AddMinutes(5)); + } + + private static HubFlowPayload CreateValidPayload() + { + var payload = new HubFlowPayload(); + + payload.Set( + "authorization_code", + AuthorizationCode); + + payload.Set( + "code_verifier", + CodeVerifier); + + return payload; + } + + private static readonly DateTimeOffset Now = + new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); + + private static Fixture CreateFixture() + { + var store = new Mock(); + var clock = new Mock(); + + clock + .SetupGet(x => x.UtcNow) + .Returns(Now); + + return new Fixture( + new HubCredentialResolver( + store.Object, + clock.Object), + store, + clock); + } + + private sealed record Fixture( + HubCredentialResolver Sut, + Mock Store, + Mock Clock); + + private sealed class TestAuthArtifact : AuthArtifact + { + public TestAuthArtifact(DateTimeOffset expiresAt) + : base( + AuthArtifactType.Custom, + expiresAt) + { + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HubFlowReaderTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HubFlowReaderTests.cs new file mode 100644 index 00000000..ab924b2c --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HubFlowReaderTests.cs @@ -0,0 +1,407 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Stores; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class HubFlowReaderTests +{ + private static readonly DateTimeOffset Now = + new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); + + private const string ReturnUrl = "/dashboard"; + + // --------------------------------------------------------------------- + // Artifact resolution + // --------------------------------------------------------------------- + + [Fact] + public async Task GetStateAsync_WhenArtifactDoesNotExist_ShouldReturnNull() + { + var fixture = CreateFixture(); + var hubSessionId = HubSessionId.New(); + + fixture.Store + .Setup(x => x.GetAsync( + It.Is(key => + key.Value == hubSessionId.Value), + It.IsAny())) + .ReturnsAsync((AuthArtifact?)null); + + var result = await fixture.Sut.GetStateAsync(hubSessionId); + + result.Should().BeNull(); + } + + [Fact] + public async Task GetStateAsync_WhenArtifactIsNotHubFlow_ShouldReturnNull() + { + var fixture = CreateFixture(); + var hubSessionId = HubSessionId.New(); + + fixture.Store + .Setup(x => x.GetAsync( + It.Is(key => + key.Value == hubSessionId.Value), + It.IsAny())) + .ReturnsAsync( + new TestAuthArtifact( + Now.AddMinutes(5))); + + var result = await fixture.Sut.GetStateAsync(hubSessionId); + + result.Should().BeNull(); + } + + // --------------------------------------------------------------------- + // Active state + // --------------------------------------------------------------------- + + [Fact] + public async Task GetStateAsync_WhenFlowIsActive_ShouldReturnActiveState() + { + var fixture = CreateFixture(); + + var artifact = CreateArtifact( + expiresAt: Now.AddMinutes(5)); + + SetupArtifact(fixture, artifact); + + var result = await fixture.Sut.GetStateAsync( + artifact.HubSessionId); + + result.Should().NotBeNull(); + + result!.Exists.Should().BeTrue(); + result.IsActive.Should().BeTrue(); + result.IsExpired.Should().BeFalse(); + result.IsCompleted.Should().BeFalse(); + } + + // --------------------------------------------------------------------- + // Expiration + // --------------------------------------------------------------------- + + [Fact] + public async Task GetStateAsync_WhenFlowIsExpired_ShouldReturnExpiredState() + { + var fixture = CreateFixture(); + + var artifact = CreateArtifact( + expiresAt: Now.AddMinutes(-1)); + + SetupArtifact(fixture, artifact); + + var result = await fixture.Sut.GetStateAsync( + artifact.HubSessionId); + + result.Should().NotBeNull(); + + result!.Exists.Should().BeTrue(); + result.IsActive.Should().BeFalse(); + result.IsExpired.Should().BeTrue(); + result.IsCompleted.Should().BeFalse(); + } + + [Fact] + public async Task GetStateAsync_WhenFlowExpiresExactlyNow_ShouldReturnExpiredState() + { + var fixture = CreateFixture(); + + var artifact = CreateArtifact( + expiresAt: Now); + + SetupArtifact(fixture, artifact); + + var result = await fixture.Sut.GetStateAsync( + artifact.HubSessionId); + + result.Should().NotBeNull(); + + result!.Exists.Should().BeTrue(); + result.IsActive.Should().BeFalse(); + result.IsExpired.Should().BeTrue(); + result.IsCompleted.Should().BeFalse(); + } + + // --------------------------------------------------------------------- + // Completion + // --------------------------------------------------------------------- + + [Fact] + public async Task GetStateAsync_WhenFlowIsCompleted_ShouldReturnCompletedState() + { + var fixture = CreateFixture(); + + var artifact = CreateArtifact( + expiresAt: Now.AddMinutes(5)); + + artifact.MarkCompleted(); + + SetupArtifact(fixture, artifact); + + var result = await fixture.Sut.GetStateAsync( + artifact.HubSessionId); + + result.Should().NotBeNull(); + + result!.Exists.Should().BeTrue(); + result.IsActive.Should().BeFalse(); + result.IsExpired.Should().BeFalse(); + result.IsCompleted.Should().BeTrue(); + } + + [Fact] + public async Task GetStateAsync_WhenFlowIsExpiredAndCompleted_ShouldPreserveBothStates() + { + var fixture = CreateFixture(); + + var artifact = CreateArtifact( + expiresAt: Now.AddMinutes(-1)); + + artifact.MarkCompleted(); + + SetupArtifact(fixture, artifact); + + var result = await fixture.Sut.GetStateAsync( + artifact.HubSessionId); + + result.Should().NotBeNull(); + + result!.Exists.Should().BeTrue(); + result.IsActive.Should().BeFalse(); + result.IsExpired.Should().BeTrue(); + result.IsCompleted.Should().BeTrue(); + } + + // --------------------------------------------------------------------- + // Mapping + // --------------------------------------------------------------------- + + [Fact] + public async Task GetStateAsync_ShouldMapHubFlowState() + { + var fixture = CreateFixture(); + + var artifact = CreateArtifact( + flowType: HubFlowType.Reauthentication, + clientProfile: UAuthClientProfile.BlazorWasm, + returnUrl: "/account/security"); + + SetupArtifact(fixture, artifact); + + var result = await fixture.Sut.GetStateAsync( + artifact.HubSessionId); + + result.Should().NotBeNull(); + + result!.HubSessionId.Should().Be( + artifact.HubSessionId); + + result.FlowType.Should().Be( + HubFlowType.Reauthentication); + + result.ClientProfile.Should().Be( + UAuthClientProfile.BlazorWasm); + + result.ReturnUrl.Should().Be( + "/account/security"); + + result.AttemptCount.Should().Be(0); + + result.Error.Should().BeNull(); + + result.Exists.Should().BeTrue(); + } + + [Fact] + public async Task GetStateAsync_ShouldMapErrorAndAttemptCount() + { + var fixture = CreateFixture(); + + var artifact = CreateArtifact(); + + var error = GetTestHubErrorCode(); + + artifact.SetError(error); + + SetupArtifact(fixture, artifact); + + var result = await fixture.Sut.GetStateAsync( + artifact.HubSessionId); + + result.Should().NotBeNull(); + + result!.Error.Should().Be(error); + result.AttemptCount.Should().Be(1); + } + + [Fact] + public async Task GetStateAsync_ShouldPreserveMultipleAttempts() + { + var fixture = CreateFixture(); + + var artifact = CreateArtifact(); + + var error = GetTestHubErrorCode(); + + artifact.SetError(error); + artifact.ClearError(); + artifact.SetError(error); + + artifact.AttemptCount.Should().Be(3); + + SetupArtifact(fixture, artifact); + + var result = await fixture.Sut.GetStateAsync( + artifact.HubSessionId); + + result.Should().NotBeNull(); + + result!.AttemptCount.Should().Be(3); + result.Error.Should().Be(error); + } + + // --------------------------------------------------------------------- + // Store interaction + // --------------------------------------------------------------------- + + [Fact] + public async Task GetStateAsync_ShouldReadArtifactUsingHubSessionId() + { + var fixture = CreateFixture(); + + var artifact = CreateArtifact(); + + SetupArtifact(fixture, artifact); + + await fixture.Sut.GetStateAsync( + artifact.HubSessionId); + + fixture.Store.Verify( + x => x.GetAsync( + It.Is(key => + key.Value == artifact.HubSessionId.Value), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task GetStateAsync_ShouldPropagateCancellationToken() + { + var fixture = CreateFixture(); + + var artifact = CreateArtifact(); + + using var cts = new CancellationTokenSource(); + + fixture.Store + .Setup(x => x.GetAsync( + It.Is(key => + key.Value == artifact.HubSessionId.Value), + cts.Token)) + .ReturnsAsync(artifact); + + await fixture.Sut.GetStateAsync( + artifact.HubSessionId, + cts.Token); + + fixture.Store.Verify( + x => x.GetAsync( + It.Is(key => + key.Value == artifact.HubSessionId.Value), + cts.Token), + Times.Once); + } + + // --------------------------------------------------------------------- + // Fixture + // --------------------------------------------------------------------- + + private static Fixture CreateFixture() + { + var store = new Mock(); + + var clock = new Mock(); + + clock + .SetupGet(x => x.UtcNow) + .Returns(Now); + + var sut = new HubFlowReader( + store.Object, + clock.Object); + + return new Fixture( + sut, + store, + clock); + } + + private static HubFlowArtifact CreateArtifact( + DateTimeOffset? expiresAt = null, + HubFlowType flowType = HubFlowType.Login, + UAuthClientProfile clientProfile = UAuthClientProfile.BlazorWasm, + string? returnUrl = ReturnUrl) + { + var payload = new HubFlowPayload(); + + payload.Set( + "authorization_code", + "authorization-code"); + + payload.Set( + "code_verifier", + "code-verifier"); + + return new HubFlowArtifact( + HubSessionId.New(), + flowType, + clientProfile, + TenantKey.Single, + TestDevice.Default(), + returnUrl, + payload, + expiresAt ?? Now.AddMinutes(5)); + } + + private static void SetupArtifact( + Fixture fixture, + HubFlowArtifact artifact) + { + fixture.Store + .Setup(x => x.GetAsync( + It.Is(key => + key.Value == artifact.HubSessionId.Value), + It.IsAny())) + .ReturnsAsync(artifact); + } + + private static HubErrorCode GetTestHubErrorCode() + { + return Enum.GetValues() + .First(); + } + + private sealed record Fixture( + HubFlowReader Sut, + Mock Store, + Mock Clock); + + private sealed class TestAuthArtifact : AuthArtifact + { + public TestAuthArtifact( + DateTimeOffset expiresAt) + : base( + AuthArtifactType.Custom, + expiresAt) + { + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HubFlowServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HubFlowServiceTests.cs new file mode 100644 index 00000000..77e764b2 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HubFlowServiceTests.cs @@ -0,0 +1,688 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Contracts; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Server.Stores; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.Extensions.Options; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class HubFlowServiceTests +{ + private static readonly DateTimeOffset Now = + new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); + + private static readonly TimeSpan FlowLifetime = + TimeSpan.FromMinutes(10); + + private const string AuthorizationCode = "authorization-code"; + private const string CodeVerifier = "code-verifier"; + private const string ReturnUrl = "/dashboard"; + + // --------------------------------------------------------------------- + // BeginLoginAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task BeginLoginAsync_ShouldCreateAndStoreHubFlowArtifact() + { + var fixture = CreateFixture(); + + var request = CreateBeginRequest(); + + AuthArtifactKey? capturedKey = null; + AuthArtifact? capturedArtifact = null; + + fixture.Store + .Setup(x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (key, artifact, _) => + { + capturedKey = key; + capturedArtifact = artifact; + }) + .Returns(Task.CompletedTask); + + var result = await fixture.Sut.BeginLoginAsync(request); + + result.Should().NotBeNull(); + result.HubSessionId.Should().NotBeNullOrWhiteSpace(); + + capturedKey.Should().NotBeNull(); + capturedArtifact.Should().BeOfType(); + + var flow = (HubFlowArtifact)capturedArtifact!; + + capturedKey!.Value.Should().Be(result.HubSessionId); + flow.HubSessionId.Value.Should().Be(result.HubSessionId); + + flow.FlowType.Should().Be(HubFlowType.Login); + flow.ClientProfile.Should().Be(request.ClientProfile); + flow.Tenant.Should().Be(request.Tenant); + flow.Device.Should().BeSameAs(request.Device); + flow.ReturnUrl.Should().Be(request.ReturnUrl); + + flow.IsCompleted.Should().BeFalse(); + flow.AttemptCount.Should().Be(0); + } + + [Fact] + public async Task BeginLoginAsync_ShouldStoreAuthorizationCodeAndCodeVerifier() + { + var fixture = CreateFixture(); + + HubFlowArtifact? captured = null; + + fixture.Store + .Setup(x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (_, artifact, _) => + { + captured = artifact as HubFlowArtifact; + }) + .Returns(Task.CompletedTask); + + await fixture.Sut.BeginLoginAsync( + CreateBeginRequest()); + + captured.Should().NotBeNull(); + + captured!.Payload + .GetRequired("authorization_code") + .Should().Be(AuthorizationCode); + + captured.Payload + .GetRequired("code_verifier") + .Should().Be(CodeVerifier); + } + + [Fact] + public async Task BeginLoginAsync_ShouldSetExpirationFromClockAndConfiguredLifetime() + { + var fixture = CreateFixture(); + + HubFlowArtifact? captured = null; + + fixture.Store + .Setup(x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (_, artifact, _) => + { + captured = artifact as HubFlowArtifact; + }) + .Returns(Task.CompletedTask); + + await fixture.Sut.BeginLoginAsync( + CreateBeginRequest()); + + captured.Should().NotBeNull(); + captured!.ExpiresAt.Should().Be(Now.Add(FlowLifetime)); + } + + [Fact] + public async Task BeginLoginAsync_WhenPreviousHubSessionIdExists_ShouldConsumePreviousFlow() + { + var fixture = CreateFixture(); + + const string previousId = "previous-hub-session"; + + var request = CreateBeginRequest( + previousHubSessionId: previousId); + + await fixture.Sut.BeginLoginAsync(request); + + fixture.Store.Verify( + x => x.ConsumeAsync( + It.Is(key => + key.Value == previousId), + It.IsAny()), + Times.Once); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task BeginLoginAsync_WhenPreviousHubSessionIdIsMissing_ShouldNotConsume( + string? previousHubSessionId) + { + var fixture = CreateFixture(); + + var request = CreateBeginRequest( + previousHubSessionId: previousHubSessionId); + + await fixture.Sut.BeginLoginAsync(request); + + fixture.Store.Verify( + x => x.ConsumeAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task BeginLoginAsync_ShouldPropagateCancellationTokenToStore() + { + var fixture = CreateFixture(); + + using var cts = new CancellationTokenSource(); + + await fixture.Sut.BeginLoginAsync( + CreateBeginRequest(), + cts.Token); + + fixture.Store.Verify( + x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + cts.Token), + Times.Once); + } + + [Fact] + public async Task BeginLoginAsync_WithPreviousFlow_ShouldPropagateCancellationTokenToConsume() + { + var fixture = CreateFixture(); + + using var cts = new CancellationTokenSource(); + + await fixture.Sut.BeginLoginAsync( + CreateBeginRequest( + previousHubSessionId: "previous-session"), + cts.Token); + + fixture.Store.Verify( + x => x.ConsumeAsync( + It.Is(key => + key.Value == "previous-session"), + cts.Token), + Times.Once); + } + + // --------------------------------------------------------------------- + // ContinuePkceAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task ContinuePkceAsync_ShouldReplacePkceCredentials() + { + var fixture = CreateFixture(); + + var artifact = CreateArtifact(); + + SetupArtifact(fixture, artifact); + + const string newAuthorizationCode = "new-authorization-code"; + const string newCodeVerifier = "new-code-verifier"; + + await fixture.Sut.ContinuePkceAsync( + artifact.HubSessionId.Value, + newAuthorizationCode, + newCodeVerifier); + + artifact.Payload + .GetRequired("authorization_code") + .Should().Be(newAuthorizationCode); + + artifact.Payload + .GetRequired("code_verifier") + .Should().Be(newCodeVerifier); + } + + [Fact] + public async Task ContinuePkceAsync_ShouldClearExistingError() + { + var fixture = CreateFixture(); + + var artifact = CreateArtifact(); + + // Use any actual HubErrorCode value from the project. + artifact.SetError(GetTestHubErrorCode()); + + artifact.Error.Should().NotBeNull(); + + var attemptsBeforeContinuation = artifact.AttemptCount; + + SetupArtifact(fixture, artifact); + + await fixture.Sut.ContinuePkceAsync( + artifact.HubSessionId.Value, + "new-code", + "new-verifier"); + + artifact.Error.Should().BeNull(); + + // ClearError() currently registers another attempt. + artifact.AttemptCount.Should().Be( + attemptsBeforeContinuation + 1); + } + + [Fact] + public async Task ContinuePkceAsync_ShouldStoreUpdatedArtifact() + { + var fixture = CreateFixture(); + + var artifact = CreateArtifact(); + + SetupArtifact(fixture, artifact); + + await fixture.Sut.ContinuePkceAsync( + artifact.HubSessionId.Value, + "new-code", + "new-verifier"); + + fixture.Store.Verify( + x => x.StoreAsync( + It.Is(key => + key.Value == artifact.HubSessionId.Value), + It.Is(stored => + ReferenceEquals(stored, artifact)), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ContinuePkceAsync_WhenArtifactDoesNotExist_ShouldThrowValidationError() + { + var fixture = CreateFixture(); + + fixture.Store + .Setup(x => x.GetAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync((AuthArtifact?)null); + + var act = () => fixture.Sut.ContinuePkceAsync( + "missing-hub-session", + AuthorizationCode, + CodeVerifier); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should().Be("Hub session not found."); + + fixture.Store.Verify( + x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ContinuePkceAsync_WhenArtifactIsWrongType_ShouldThrow() + { + var fixture = CreateFixture(); + + fixture.Store + .Setup(x => x.GetAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync( + new TestAuthArtifact( + Now.AddMinutes(5))); + + var act = () => fixture.Sut.ContinuePkceAsync( + "hub-session", + AuthorizationCode, + CodeVerifier); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should().Be("Hub session not found."); + + fixture.Store.Verify( + x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + // --------------------------------------------------------------------- + // SECURITY / LIFECYCLE INVARIANTS + // These two are expected to expose the current implementation. + // --------------------------------------------------------------------- + + [Fact] + public async Task ContinuePkceAsync_WhenFlowIsExpired_ShouldRejectContinuation() + { + var fixture = CreateFixture(); + + var artifact = CreateArtifact( + expiresAt: Now.AddMinutes(-1)); + + SetupArtifact(fixture, artifact); + + var originalCode = + artifact.Payload.GetRequired( + "authorization_code"); + + var originalVerifier = + artifact.Payload.GetRequired( + "code_verifier"); + + var act = () => fixture.Sut.ContinuePkceAsync( + artifact.HubSessionId.Value, + "attacker-new-code", + "attacker-new-verifier"); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should().Be("Hub session expired."); + + artifact.Payload + .GetRequired("authorization_code") + .Should().Be(originalCode); + + artifact.Payload + .GetRequired("code_verifier") + .Should().Be(originalVerifier); + + fixture.Store.Verify( + x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ContinuePkceAsync_WhenFlowExpiresExactlyNow_ShouldRejectContinuation() + { + var fixture = CreateFixture(); + + var artifact = CreateArtifact( + expiresAt: Now); + + SetupArtifact(fixture, artifact); + + var act = () => fixture.Sut.ContinuePkceAsync( + artifact.HubSessionId.Value, + "new-code", + "new-verifier"); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should().Be("Hub session expired."); + + fixture.Store.Verify( + x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ContinuePkceAsync_WhenFlowIsCompleted_ShouldRejectContinuation() + { + var fixture = CreateFixture(); + + var artifact = CreateArtifact(); + + artifact.MarkCompleted(); + + SetupArtifact(fixture, artifact); + + var originalCode = + artifact.Payload.GetRequired( + "authorization_code"); + + var originalVerifier = + artifact.Payload.GetRequired( + "code_verifier"); + + var act = () => fixture.Sut.ContinuePkceAsync( + artifact.HubSessionId.Value, + "new-code", + "new-verifier"); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should().Be("Hub session already completed."); + + artifact.Payload + .GetRequired("authorization_code") + .Should().Be(originalCode); + + artifact.Payload + .GetRequired("code_verifier") + .Should().Be(originalVerifier); + + fixture.Store.Verify( + x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ContinuePkceAsync_ShouldPropagateCancellationToken() + { + var fixture = CreateFixture(); + + var artifact = CreateArtifact(); + + using var cts = new CancellationTokenSource(); + + fixture.Store + .Setup(x => x.GetAsync( + It.Is(key => + key.Value == artifact.HubSessionId.Value), + cts.Token)) + .ReturnsAsync(artifact); + + await fixture.Sut.ContinuePkceAsync( + artifact.HubSessionId.Value, + "new-code", + "new-verifier", + cts.Token); + + fixture.Store.Verify( + x => x.GetAsync( + It.IsAny(), + cts.Token), + Times.Once); + + fixture.Store.Verify( + x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + cts.Token), + Times.Once); + } + + // --------------------------------------------------------------------- + // ConsumeAsync + // --------------------------------------------------------------------- + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task ConsumeAsync_WhenHubSessionIdIsEmpty_ShouldDoNothing( + string hubSessionId) + { + var fixture = CreateFixture(); + + await fixture.Sut.ConsumeAsync(hubSessionId); + + fixture.Store.Verify( + x => x.ConsumeAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ConsumeAsync_WhenHubSessionIdExists_ShouldConsumeArtifact() + { + var fixture = CreateFixture(); + + const string hubSessionId = "hub-session"; + + await fixture.Sut.ConsumeAsync(hubSessionId); + + fixture.Store.Verify( + x => x.ConsumeAsync( + It.Is(key => + key.Value == hubSessionId), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ConsumeAsync_ShouldPropagateCancellationToken() + { + var fixture = CreateFixture(); + + using var cts = new CancellationTokenSource(); + + await fixture.Sut.ConsumeAsync( + "hub-session", + cts.Token); + + fixture.Store.Verify( + x => x.ConsumeAsync( + It.Is(key => + key.Value == "hub-session"), + cts.Token), + Times.Once); + } + + // --------------------------------------------------------------------- + // Fixture + // --------------------------------------------------------------------- + + private static Fixture CreateFixture() + { + var store = new Mock(); + + store + .Setup(x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + store + .Setup(x => x.ConsumeAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync((AuthArtifact?)null); + + var clock = new Mock(); + + clock + .SetupGet(x => x.UtcNow) + .Returns(Now); + + var options = new UAuthServerOptions(); + + options.Hub.FlowLifetime = FlowLifetime; + + var sut = new HubFlowService( + store.Object, + clock.Object, + Options.Create(options)); + + return new Fixture( + sut, + store, + clock); + } + + private static HubBeginRequest CreateBeginRequest( + string? previousHubSessionId = null) + { + return new HubBeginRequest + { + AuthorizationCode = AuthorizationCode, + CodeVerifier = CodeVerifier, + ClientProfile = UAuthClientProfile.BlazorWasm, + Tenant = TenantKey.Single, + Device = TestDevice.Default(), + ReturnUrl = ReturnUrl, + PreviousHubSessionId = previousHubSessionId + }; + } + + private static HubFlowArtifact CreateArtifact( + DateTimeOffset? expiresAt = null) + { + var payload = new HubFlowPayload(); + + payload.Set( + "authorization_code", + AuthorizationCode); + + payload.Set( + "code_verifier", + CodeVerifier); + + return new HubFlowArtifact( + HubSessionId.New(), + HubFlowType.Login, + UAuthClientProfile.BlazorWasm, + TenantKey.Single, + TestDevice.Default(), + ReturnUrl, + payload, + expiresAt ?? Now.Add(FlowLifetime)); + } + + private static void SetupArtifact( + Fixture fixture, + HubFlowArtifact artifact) + { + fixture.Store + .Setup(x => x.GetAsync( + It.Is(key => + key.Value == artifact.HubSessionId.Value), + It.IsAny())) + .ReturnsAsync(artifact); + } + + private static HubErrorCode GetTestHubErrorCode() + { + return Enum.GetValues() + .First(); + } + + private sealed record Fixture( + HubFlowService Sut, + Mock Store, + Mock Clock); + + private sealed class TestAuthArtifact : AuthArtifact + { + public TestAuthArtifact(DateTimeOffset expiresAt) + : base( + AuthArtifactType.Custom, + expiresAt) + { + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceEndpointHandlerTests.cs index 19837618..9ec29dbe 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceEndpointHandlerTests.cs @@ -276,7 +276,7 @@ public async Task TryCompleteAsync_WhenPkceValidationFails_RequestsNewPkce() } [Fact] - public async Task TryCompleteAsync_WhenPkceIsValid_UsesArtifactContextAndPreviewLogin() + public async Task TryCompleteAsync_WhenPkceIsValid_ValidatesCurrentContextAndUsesArtifactContextForPreviewLogin() { var fixture = CreateFixture(); var (artifact, verifier) = TestPkceFactory.Create(); @@ -344,19 +344,27 @@ public async Task TryCompleteAsync_WhenPkceIsValid_UsesArtifactContextAndPreview ok.Value!.Success.Should().BeTrue(); ok.Value.RetryWithNewPkce.Should().BeFalse(); + // SECURITY: + // PKCE validation must use the CURRENT completion context, + // not the context copied from the authorization artifact. capturedSnapshot.Should().NotBeNull(); + capturedSnapshot!.ClientProfile - .Should().Be(artifact.Context.ClientProfile); + .Should().Be(fixture.Flow.ClientProfile); + capturedSnapshot.Tenant - .Should().Be(artifact.Context.Tenant); - capturedSnapshot.RedirectUri - .Should().Be(artifact.Context.RedirectUri); + .Should().Be(fixture.Flow.Tenant); + capturedSnapshot.Device - .Should().BeEquivalentTo(artifact.Context.Device); + .Should().BeEquivalentTo(fixture.Flow.Device); + // After PKCE validation succeeds, login execution remains bound + // to the context captured when the authorization code was issued. capturedExecution.Should().NotBeNull(); + capturedExecution!.EffectiveClientProfile .Should().Be(artifact.Context.ClientProfile); + capturedExecution.Device .Should().BeEquivalentTo(artifact.Context.Device); @@ -367,9 +375,14 @@ public async Task TryCompleteAsync_WhenPkceIsValid_UsesArtifactContextAndPreview .Should().Be(fixture.Flow.AllowsTokenIssuance); capturedOptions.Should().NotBeNull(); - capturedOptions!.Mode.Should().Be(LoginExecutionMode.Preview); - capturedOptions.SuppressFailureAttempt.Should().BeFalse(); - capturedOptions.SuppressSuccessReset.Should().BeTrue(); + capturedOptions!.Mode + .Should().Be(LoginExecutionMode.Preview); + + capturedOptions.SuppressFailureAttempt + .Should().BeFalse(); + + capturedOptions.SuppressSuccessReset + .Should().BeTrue(); } [Fact] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceServiceTests.cs new file mode 100644 index 00000000..5a6c4fee --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceServiceTests.cs @@ -0,0 +1,853 @@ +using System.Security.Cryptography; +using System.Text; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Server.Stores; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.Extensions.Options; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class PkceServiceTests +{ + private static readonly DateTimeOffset Now = + new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); + + private const int AuthorizationCodeLifetimeSeconds = 120; + + // --------------------------------------------------------------------- + // Authorize + // --------------------------------------------------------------------- + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task AuthorizeAsync_WhenCodeChallengeIsMissing_ShouldReject( + string? codeChallenge) + { + var fixture = CreateFixture(); + + var command = CreateAuthorizeCommand( + codeChallenge: codeChallenge!); + + var act = () => + fixture.Sut.AuthorizeAsync(command); + + await act.Should() + .ThrowAsync(); + + fixture.Store.Verify( + x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Theory] + [InlineData("")] + [InlineData("plain")] + [InlineData("s256")] + [InlineData("S512")] + public async Task AuthorizeAsync_WhenChallengeMethodIsNotExactS256_ShouldReject( + string challengeMethod) + { + var fixture = CreateFixture(); + + var command = CreateAuthorizeCommand( + challengeMethod: challengeMethod); + + var act = () => + fixture.Sut.AuthorizeAsync(command); + + await act.Should() + .ThrowAsync(); + + fixture.Store.Verify( + x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task AuthorizeAsync_ShouldStorePkceAuthorizationArtifact() + { + var fixture = CreateFixture(); + + var device = CreateDevice(); + + var command = CreateAuthorizeCommand( + device: device, + redirectUri: "/callback"); + + AuthArtifactKey? capturedKey = null; + PkceAuthorizationArtifact? capturedArtifact = null; + + fixture.Store + .Setup(x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (key, artifact, _) => + { + capturedKey = key; + capturedArtifact = + artifact as PkceAuthorizationArtifact; + }) + .Returns(Task.CompletedTask); + + var result = + await fixture.Sut.AuthorizeAsync(command); + + capturedKey.Should().NotBeNull(); + capturedArtifact.Should().NotBeNull(); + + capturedArtifact!.AuthorizationCode + .Should().Be(capturedKey); + + capturedArtifact.CodeChallenge + .Should().Be(command.CodeChallenge); + + capturedArtifact.ChallengeMethod + .Should().Be(PkceChallengeMethod.S256); + + capturedArtifact.ExpiresAt + .Should().Be( + Now.AddSeconds( + AuthorizationCodeLifetimeSeconds)); + + capturedArtifact.Context.ClientProfile + .Should().Be(command.ClientProfile); + + capturedArtifact.Context.Tenant + .Should().Be(command.Tenant); + + capturedArtifact.Context.RedirectUri + .Should().Be(command.RedirectUri); + + capturedArtifact.Context.Device + .Should().BeSameAs(device); + + result.ExpiresIn + .Should().Be( + AuthorizationCodeLifetimeSeconds); + } + + [Fact] + public async Task AuthorizeAsync_ShouldPropagateCancellationTokenToStore() + { + var fixture = CreateFixture(); + + using var cts = + new CancellationTokenSource(); + + var command = CreateAuthorizeCommand(); + + await fixture.Sut.AuthorizeAsync( + command, + cts.Token); + + fixture.Store.Verify( + x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + cts.Token), + Times.Once); + } + + // --------------------------------------------------------------------- + // Complete + // --------------------------------------------------------------------- + + [Fact] + public async Task CompleteAsync_WhenAuthorizationArtifactDoesNotExist_ShouldReturnInvalidPkce() + { + var fixture = CreateFixture(); + + fixture.Store + .Setup(x => x.ConsumeAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync((AuthArtifact?)null); + + var auth = AuthFlowTestFactory.New(); + var request = CreateCompleteRequest(); + + var result = + await fixture.Sut.CompleteAsync( + auth, + request); + + result.InvalidPkce.Should().BeTrue(); + + fixture.Validator.Verify( + x => x.Validate( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + + fixture.Flow.Verify( + x => x.LoginAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task CompleteAsync_ShouldConsumeAuthorizationCodeUsingRequestCode() + { + var fixture = CreateFixture(); + + const string authorizationCode = + "authorization-code"; + + fixture.Store + .Setup(x => x.ConsumeAsync( + It.Is( + key => + key.Value == authorizationCode), + It.IsAny())) + .ReturnsAsync((AuthArtifact?)null); + + var request = CreateCompleteRequest( + authorizationCode: authorizationCode); + + await fixture.Sut.CompleteAsync( + AuthFlowTestFactory.New(), + request); + + fixture.Store.Verify( + x => x.ConsumeAsync( + It.Is( + key => + key.Value == authorizationCode), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task CompleteAsync_WhenPkceValidationFails_ShouldNotAttemptLogin() + { + var fixture = CreateFixture(); + + var artifact = CreatePkceArtifact(); + + SetupConsumedArtifact( + fixture, + artifact); + + fixture.Validator + .Setup(x => x.Validate( + artifact, + It.IsAny(), + It.IsAny(), + Now)) + .Returns(CreateFailedValidation()); + + var result = + await fixture.Sut.CompleteAsync( + AuthFlowTestFactory.New(), + CreateCompleteRequest()); + + result.Success.Should().BeFalse(); + result.FailureReason + .Should().Be( + AuthFailureReason.InvalidCredentials); + + fixture.Flow.Verify( + x => x.LoginAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task CompleteAsync_WhenValidationSucceeds_ShouldPassCredentialsToLogin() + { + var fixture = CreateFixture(); + + var artifact = CreatePkceArtifact(); + + SetupSuccessfulValidation( + fixture, + artifact); + + LoginRequest? captured = null; + + fixture.Flow + .Setup(x => x.LoginAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback< + AuthFlowContext, + AuthExecutionContext, + LoginRequest, + CancellationToken>( + (_, _, request, _) => + { + captured = request; + }) + .ReturnsAsync(CreateSuccessfulLoginResult()); + + var request = CreateCompleteRequest( + identifier: "alice@example.com", + secret: "correct-password"); + + await fixture.Sut.CompleteAsync( + AuthFlowTestFactory.New(), + request); + + captured.Should().NotBeNull(); + + captured!.Identifier + .Should().Be("alice@example.com"); + + captured.Secret + .Should().Be("correct-password"); + } + + [Fact] + public async Task CompleteAsync_WhenValidationSucceeds_ShouldUseArtifactExecutionContext() + { + var fixture = CreateFixture(); + + var device = CreateDevice(); + + var artifact = CreatePkceArtifact( + clientProfile: + UAuthClientProfile.BlazorWasm, + device: device); + + SetupSuccessfulValidation( + fixture, + artifact); + + AuthExecutionContext? captured = null; + + fixture.Flow + .Setup(x => x.LoginAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback< + AuthFlowContext, + AuthExecutionContext, + LoginRequest, + CancellationToken>( + (_, execution, _, _) => + { + captured = execution; + }) + .ReturnsAsync(CreateSuccessfulLoginResult()); + + await fixture.Sut.CompleteAsync( + AuthFlowTestFactory.New(), + CreateCompleteRequest()); + + captured.Should().NotBeNull(); + + captured!.EffectiveClientProfile + .Should().Be( + UAuthClientProfile.BlazorWasm); + + captured.Device + .Should().BeSameAs(device); + } + + [Fact] + public async Task CompleteAsync_ShouldPropagateCancellationTokenToConsumeAndLogin() + { + var fixture = CreateFixture(); + + var artifact = CreatePkceArtifact(); + + SetupSuccessfulValidation( + fixture, + artifact); + + fixture.Flow + .Setup(x => x.LoginAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(CreateSuccessfulLoginResult()); + + using var cts = + new CancellationTokenSource(); + + await fixture.Sut.CompleteAsync( + AuthFlowTestFactory.New(), + CreateCompleteRequest(), + cts.Token); + + fixture.Store.Verify( + x => x.ConsumeAsync( + It.IsAny(), + cts.Token), + Times.Once); + + fixture.Flow.Verify( + x => x.LoginAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + cts.Token), + Times.Once); + } + + // --------------------------------------------------------------------- + // Refresh + // --------------------------------------------------------------------- + + [Fact] + public async Task RefreshAsync_WhenOldAuthorizationCodeExists_ShouldConsumeIt() + { + var fixture = CreateFixture(); + + var hub = CreateHubArtifact( + authorizationCode: "old-code"); + + await fixture.Sut.RefreshAsync(hub); + + fixture.Store.Verify( + x => x.ConsumeAsync( + It.Is( + key => + key.Value == "old-code"), + It.IsAny()), + Times.Once); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task RefreshAsync_WhenOldAuthorizationCodeDoesNotExist_ShouldNotConsume( + string? oldCode) + { + var fixture = CreateFixture(); + + var hub = CreateHubArtifact( + authorizationCode: oldCode); + + await fixture.Sut.RefreshAsync(hub); + + fixture.Store.Verify( + x => x.ConsumeAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RefreshAsync_ShouldCreateNewPkceArtifactWithHubContext() + { + var fixture = CreateFixture(); + + var device = CreateDevice(); + + var hub = CreateHubArtifact( + authorizationCode: "old-code", + device: device, + returnUrl: "/orders"); + + PkceAuthorizationArtifact? captured = null; + + fixture.Store + .Setup(x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (_, artifact, _) => + { + captured = + artifact as PkceAuthorizationArtifact; + }) + .Returns(Task.CompletedTask); + + var result = + await fixture.Sut.RefreshAsync(hub); + + captured.Should().NotBeNull(); + + captured!.AuthorizationCode.Value + .Should().Be( + result.AuthorizationCode); + + captured.ChallengeMethod + .Should().Be( + PkceChallengeMethod.S256); + + captured.ExpiresAt + .Should().Be( + Now.AddSeconds( + AuthorizationCodeLifetimeSeconds)); + + captured.Context.ClientProfile + .Should().Be(hub.ClientProfile); + + captured.Context.Tenant + .Should().Be(hub.Tenant); + + captured.Context.RedirectUri + .Should().Be(hub.ReturnUrl); + + captured.Context.Device + .Should().BeSameAs(device); + } + + [Fact] + public async Task RefreshAsync_ReturnedVerifier_ShouldMatchStoredS256Challenge() + { + var fixture = CreateFixture(); + + var hub = CreateHubArtifact(); + + PkceAuthorizationArtifact? captured = null; + + fixture.Store + .Setup(x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (_, artifact, _) => + { + captured = + artifact as PkceAuthorizationArtifact; + }) + .Returns(Task.CompletedTask); + + var result = + await fixture.Sut.RefreshAsync(hub); + + captured.Should().NotBeNull(); + + CreateS256Challenge(result.CodeVerifier) + .Should().Be(captured!.CodeChallenge); + } + + [Fact] + public async Task RefreshAsync_ShouldPropagateCancellationTokenToStoreOperations() + { + var fixture = CreateFixture(); + + var hub = CreateHubArtifact( + authorizationCode: "old-code"); + + using var cts = + new CancellationTokenSource(); + + await fixture.Sut.RefreshAsync( + hub, + cts.Token); + + fixture.Store.Verify( + x => x.ConsumeAsync( + It.Is( + key => key.Value == "old-code"), + cts.Token), + Times.Once); + + fixture.Store.Verify( + x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + cts.Token), + Times.Once); + } + + [Fact] + public async Task CompleteAsync_WhenLoginFails_ShouldMapLoginFailure() + { + var fixture = CreateFixture(); + + var artifact = CreatePkceArtifact(); + + SetupSuccessfulValidation( + fixture, + artifact); + + var loginResult = + LoginResult.Failed( + AuthFailureReason.InvalidCredentials); + + fixture.Flow + .Setup(x => x.LoginAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(loginResult); + + var result = + await fixture.Sut.CompleteAsync( + AuthFlowTestFactory.New(), + CreateCompleteRequest()); + + result.Success.Should().BeFalse(); + + result.FailureReason.Should().Be( + AuthFailureReason.InvalidCredentials); + + result.LoginResult.Should().BeSameAs(loginResult); + } + + [Fact] + public async Task CompleteAsync_WhenLoginSucceeds_ShouldReturnLoginResult() + { + var fixture = CreateFixture(); + + var artifact = CreatePkceArtifact(); + + SetupSuccessfulValidation( + fixture, + artifact); + + var loginResult = + LoginResult.SuccessPreview(); + + fixture.Flow + .Setup(x => x.LoginAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(loginResult); + + var result = + await fixture.Sut.CompleteAsync( + AuthFlowTestFactory.New(), + CreateCompleteRequest()); + + result.Success.Should().BeTrue(); + result.FailureReason.Should().BeNull(); + result.LoginResult.Should().BeSameAs(loginResult); + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + private static Fixture CreateFixture() + { + var store = new Mock(); + var validator = + new Mock(); + var flow = + new Mock(); + var clock = + new Mock(); + + clock + .SetupGet(x => x.UtcNow) + .Returns(Now); + + store + .Setup(x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + var options = new UAuthServerOptions(); + + options.Pkce.AuthorizationCodeLifetimeSeconds = + AuthorizationCodeLifetimeSeconds; + + var sut = new PkceService( + store.Object, + validator.Object, + flow.Object, + clock.Object, + Options.Create(options)); + + return new Fixture( + sut, + store, + validator, + flow); + } + + private static void SetupConsumedArtifact( + Fixture fixture, + PkceAuthorizationArtifact artifact) + { + fixture.Store + .Setup(x => x.ConsumeAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(artifact); + } + + private static void SetupSuccessfulValidation( + Fixture fixture, + PkceAuthorizationArtifact artifact) + { + SetupConsumedArtifact( + fixture, + artifact); + + fixture.Validator + .Setup(x => x.Validate( + artifact, + It.IsAny(), + It.IsAny(), + Now)) + .Returns(CreateSuccessfulValidation()); + } + + private static string CreateS256Challenge( + string verifier) + { + var bytes = + SHA256.HashData( + Encoding.UTF8.GetBytes(verifier)); + + return Convert + .ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + private sealed record Fixture( + PkceService Sut, + Mock Store, + Mock Validator, + Mock Flow); + + private static PkceAuthorizeCommand CreateAuthorizeCommand( + string codeChallenge = "valid-code-challenge", + string challengeMethod = "S256", + DeviceContext? device = null, + string? redirectUri = "/callback", + UAuthClientProfile clientProfile = UAuthClientProfile.BlazorWasm, + TenantKey? tenant = null) + { + return new PkceAuthorizeCommand + { + CodeChallenge = codeChallenge, + ChallengeMethod = challengeMethod, + Device = device ?? DeviceContext.Anonymous(), + RedirectUri = redirectUri, + ClientProfile = clientProfile, + Tenant = tenant ?? TenantKey.Single + }; + } + + private static PkceCompleteRequest CreateCompleteRequest( + string authorizationCode = "authorization-code", + string codeVerifier = "verifier", + string identifier = "alice@example.com", + string secret = "correct-password", + string? returnUrl = "/dashboard", + string? hubSessionId = null) + { + return new PkceCompleteRequest + { + AuthorizationCode = authorizationCode, + CodeVerifier = codeVerifier, + Identifier = identifier, + Secret = secret, + ReturnUrl = returnUrl, + HubSessionId = hubSessionId + }; + } + + private static PkceAuthorizationArtifact CreatePkceArtifact( + string authorizationCode = "authorization-code", + string codeChallenge = "code-challenge", + PkceChallengeMethod challengeMethod = PkceChallengeMethod.S256, + DateTimeOffset? expiresAt = null, + UAuthClientProfile clientProfile = UAuthClientProfile.BlazorWasm, + TenantKey? tenant = null, + string? redirectUri = "/callback", + DeviceContext? device = null) + { + var context = new PkceContextSnapshot( + clientProfile, + tenant ?? TenantKey.Single, + redirectUri, + device ?? DeviceContext.Anonymous()); + + return new PkceAuthorizationArtifact( + new AuthArtifactKey(authorizationCode), + codeChallenge, + challengeMethod, + expiresAt ?? Now.AddMinutes(2), + context); + } + + private static HubFlowArtifact CreateHubArtifact( + string? authorizationCode = null, + UAuthClientProfile clientProfile = UAuthClientProfile.BlazorWasm, + TenantKey? tenant = null, + DeviceContext? device = null, + string? returnUrl = "/callback") + { + var payload = new HubFlowPayload(); + + if (authorizationCode is not null) + payload.Set("authorization_code", authorizationCode); + + return new HubFlowArtifact( + HubSessionId.New(), + HubFlowType.Login, + clientProfile, + tenant ?? TenantKey.Single, + device ?? DeviceContext.Anonymous(), + returnUrl, + payload, + Now.AddMinutes(5)); + } + + private static DeviceContext CreateDevice() + { + // DeviceId factory API'sini henüz görmediğimiz için testin bu + // aşamasında anonymous olmayan sahte DeviceId üretmiyoruz. + // + // PkceService açısından önemli invariant aynı DeviceContext + // instance'ının snapshot/execution'a taşınmasıdır. + return DeviceContext.Create(DeviceId.Create("test-device-id123456789012345678901234567890"), "Desktop", "Web", "Windows", "Edge", "127.0.0.1"); + } + + private static PkceValidationResult CreateSuccessfulValidation() + { + return PkceValidationResult.Ok(); + } + + private static PkceValidationResult CreateFailedValidation() + { + return PkceValidationResult.Fail(PkceValidationFailureReason.InvalidVerifier); + } + + private static LoginResult CreateSuccessfulLoginResult() + { + return LoginResult.SuccessPreview(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceTests.cs index 702308a5..94148588 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceTests.cs @@ -30,24 +30,24 @@ public void Pkce_Should_Fail_With_Invalid_Verifier() result.FailureReason.Should().Be(PkceValidationFailureReason.InvalidVerifier); } - [Fact] - public void Pkce_Should_Fail_On_Device_Mismatch() - { - var validator = new PkceAuthorizationValidator(); - var (artifact, verifier) = TestPkceFactory.Create(); - - var wrongContext = new PkceContextSnapshot( - artifact.Context.ClientProfile, - artifact.Context.Tenant, - artifact.Context.RedirectUri, - device: TestDevice.Alternative() - ); - - var result = validator.Validate(artifact, verifier, wrongContext, DateTimeOffset.UtcNow); - - result.Success.Should().BeFalse(); - result.FailureReason.Should().Be(PkceValidationFailureReason.ContextMismatch); - } + //[Fact] + //public void Pkce_Should_Fail_On_Device_Mismatch() + //{ + // var validator = new PkceAuthorizationValidator(); + // var (artifact, verifier) = TestPkceFactory.Create(); + + // var wrongContext = new PkceContextSnapshot( + // artifact.Context.ClientProfile, + // artifact.Context.Tenant, + // artifact.Context.RedirectUri, + // device: TestDevice.Alternative() + // ); + + // var result = validator.Validate(artifact, verifier, wrongContext, DateTimeOffset.UtcNow); + + // result.Success.Should().BeFalse(); + // result.FailureReason.Should().Be(PkceValidationFailureReason.ContextMismatch); + //} [Fact] public async Task Refresh_Should_Generate_New_AuthorizationCode() diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/BearerSessionIdResolverTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/BearerSessionIdResolverTests.cs new file mode 100644 index 00000000..fabbf816 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/BearerSessionIdResolverTests.cs @@ -0,0 +1,117 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using FluentAssertions; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class BearerSessionIdResolverTests +{ + private const string ValidSessionId = + "bearer-session-0000000000000000000000001"; + + [Fact] + public void Name_ShouldBeBearer() + { + var sut = new BearerSessionIdResolver(); + + sut.Name.Should().Be("bearer"); + } + + [Fact] + public void Resolve_WhenAuthorizationHeaderMissing_ShouldReturnNull() + { + var sut = new BearerSessionIdResolver(); + var context = new DefaultHttpContext(); + + var result = sut.Resolve(context); + + result.Should().BeNull(); + } + + [Fact] + public void Resolve_WhenAuthorizationSchemeIsNotBearer_ShouldReturnNull() + { + var sut = new BearerSessionIdResolver(); + var context = new DefaultHttpContext(); + + context.Request.Headers.Authorization = + $"Basic {ValidSessionId}"; + + var result = sut.Resolve(context); + + result.Should().BeNull(); + } + + [Fact] + public void Resolve_WhenBearerValueIsEmpty_ShouldReturnNull() + { + var sut = new BearerSessionIdResolver(); + var context = new DefaultHttpContext(); + + context.Request.Headers.Authorization = "Bearer "; + + var result = sut.Resolve(context); + + result.Should().BeNull(); + } + + [Fact] + public void Resolve_WhenBearerValueIsInvalid_ShouldReturnNull() + { + var sut = new BearerSessionIdResolver(); + var context = new DefaultHttpContext(); + + context.Request.Headers.Authorization = + "Bearer invalid"; + + var result = sut.Resolve(context); + + result.Should().BeNull(); + } + + [Fact] + public void Resolve_WhenBearerValueIsValid_ShouldReturnSessionId() + { + var sut = new BearerSessionIdResolver(); + var context = new DefaultHttpContext(); + + context.Request.Headers.Authorization = + $"Bearer {ValidSessionId}"; + + var result = sut.Resolve(context); + + result.Should().NotBeNull(); + result!.Value.ToString().Should().Be(ValidSessionId); + } + + [Fact] + public void Resolve_WhenBearerSchemeUsesDifferentCasing_ShouldResolveSessionId() + { + var sut = new BearerSessionIdResolver(); + var context = new DefaultHttpContext(); + + context.Request.Headers.Authorization = + $"bEaReR {ValidSessionId}"; + + var result = sut.Resolve(context); + + result.Should().NotBeNull(); + result!.Value.ToString().Should().Be(ValidSessionId); + } + + [Fact] + public void Resolve_WhenBearerValueContainsOuterWhitespace_ShouldTrimAndResolve() + { + var sut = new BearerSessionIdResolver(); + var context = new DefaultHttpContext(); + + context.Request.Headers.Authorization = + $"Bearer {ValidSessionId} "; + + var result = sut.Resolve(context); + + result.Should().NotBeNull(); + result!.Value.ToString().Should().Be(ValidSessionId); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/CompositeSessionIdResolverTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/CompositeSessionIdResolverTests.cs new file mode 100644 index 00000000..799037b2 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/CompositeSessionIdResolverTests.cs @@ -0,0 +1,350 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class CompositeSessionIdResolverTests +{ + [Fact] + public void Resolve_ShouldUseFirstSuccessfulResolverAccordingToConfiguredOrder() + { + var firstId = + CreateSessionId( + "first-session-00000000000000000000000001"); + + var secondId = + CreateSessionId( + "second-session-0000000000000000000000001"); + + var bearer = + CreateResolver( + "bearer", + firstId); + + var cookie = + CreateResolver( + "cookie", + secondId); + + var options = TestServerOptions.Default(); + + options.SessionResolution.Order = + new List { "Bearer", "Cookie" }; + + options.SessionResolution.EnableBearer = true; + options.SessionResolution.EnableCookie = true; + + var sut = + new CompositeSessionIdResolver( + new[] + { + bearer.Object, + cookie.Object + }, + Options.Create(options)); + + var context = new DefaultHttpContext(); + + var result = sut.Resolve(context); + + result.Should().Be(firstId); + + bearer.Verify( + x => x.Resolve(context), + Times.Once); + + cookie.Verify( + x => x.Resolve(context), + Times.Never); + } + + [Fact] + public void Resolve_WhenFirstResolverReturnsNull_ShouldContinueToNextResolver() + { + var expected = + CreateSessionId( + "cookie-session-0000000000000000000000002"); + + var bearer = + CreateResolver( + "bearer", + null); + + var cookie = + CreateResolver( + "cookie", + expected); + + var options = TestServerOptions.Default(); + + options.SessionResolution.Order = + new List { "Bearer", "Cookie" }; + + options.SessionResolution.EnableBearer = true; + options.SessionResolution.EnableCookie = true; + + var sut = + new CompositeSessionIdResolver( + new[] + { + bearer.Object, + cookie.Object + }, + Options.Create(options)); + + var context = new DefaultHttpContext(); + + var result = sut.Resolve(context); + + result.Should().Be(expected); + + bearer.Verify( + x => x.Resolve(context), + Times.Once); + + cookie.Verify( + x => x.Resolve(context), + Times.Once); + } + + [Fact] + public void Resolve_WhenResolverIsDisabled_ShouldSkipIt() + { + var bearerId = + CreateSessionId( + "bearer-session-0000000000000000000000002"); + + var cookieId = + CreateSessionId( + "cookie-session-0000000000000000000000003"); + + var bearer = + CreateResolver( + "bearer", + bearerId); + + var cookie = + CreateResolver( + "cookie", + cookieId); + + var options = TestServerOptions.Default(); + + options.SessionResolution.Order = + new List { "Bearer", "Cookie" }; + + options.SessionResolution.EnableBearer = false; + options.SessionResolution.EnableCookie = true; + + var sut = + new CompositeSessionIdResolver( + new[] + { + bearer.Object, + cookie.Object + }, + Options.Create(options)); + + var context = new DefaultHttpContext(); + + var result = sut.Resolve(context); + + result.Should().Be(cookieId); + + bearer.Verify( + x => x.Resolve( + It.IsAny()), + Times.Never); + + cookie.Verify( + x => x.Resolve(context), + Times.Once); + } + + [Fact] + public void Resolve_WhenConfiguredResolverDoesNotExist_ShouldContinue() + { + var expected = + CreateSessionId( + "cookie-session-0000000000000000000000004"); + + var cookie = + CreateResolver( + "cookie", + expected); + + var options = TestServerOptions.Default(); + + options.SessionResolution.Order = + new List { "Bearer", "Cookie" }; + + options.SessionResolution.EnableBearer = true; + options.SessionResolution.EnableCookie = true; + + var sut = + new CompositeSessionIdResolver( + new[] + { + cookie.Object + }, + Options.Create(options)); + + var result = + sut.Resolve( + new DefaultHttpContext()); + + result.Should().Be(expected); + } + + [Fact] + public void Resolve_WhenOrderUsesDifferentCasing_ShouldResolveCaseInsensitively() + { + var expected = + CreateSessionId( + "bearer-session-0000000000000000000000005"); + + var bearer = + CreateResolver( + "bearer", + expected); + + var options = TestServerOptions.Default(); + + options.SessionResolution.Order = + new List { "bEaReR" }; + + options.SessionResolution.EnableBearer = true; + + var sut = + new CompositeSessionIdResolver( + new[] + { + bearer.Object + }, + Options.Create(options)); + + var result = + sut.Resolve( + new DefaultHttpContext()); + + result.Should().Be(expected); + } + + [Fact] + public void Resolve_WhenNoResolverProducesSessionId_ShouldReturnNull() + { + var bearer = + CreateResolver( + "bearer", + null); + + var cookie = + CreateResolver( + "cookie", + null); + + var options = TestServerOptions.Default(); + + options.SessionResolution.Order = + new List { "Bearer", "Cookie" }; + + options.SessionResolution.EnableBearer = true; + options.SessionResolution.EnableCookie = true; + + var sut = + new CompositeSessionIdResolver( + new[] + { + bearer.Object, + cookie.Object + }, + Options.Create(options)); + + var result = + sut.Resolve( + new DefaultHttpContext()); + + result.Should().BeNull(); + } + + [Fact] + public void Resolve_WhenOrderChanges_ShouldRespectNewPrecedence() + { + var bearerId = + CreateSessionId( + "bearer-session-0000000000000000000000006"); + + var cookieId = + CreateSessionId( + "cookie-session-0000000000000000000000006"); + + var bearer = + CreateResolver( + "bearer", + bearerId); + + var cookie = + CreateResolver( + "cookie", + cookieId); + + var options = TestServerOptions.Default(); + + options.SessionResolution.Order = + new List { "Cookie", "Bearer" }; + + options.SessionResolution.EnableBearer = true; + options.SessionResolution.EnableCookie = true; + + var sut = + new CompositeSessionIdResolver( + new[] + { + bearer.Object, + cookie.Object + }, + Options.Create(options)); + + var result = + sut.Resolve( + new DefaultHttpContext()); + + result.Should().Be(cookieId); + } + + private static Mock CreateResolver( + string name, + AuthSessionId? result) + { + var resolver = + new Mock(); + + resolver + .SetupGet(x => x.Name) + .Returns(name); + + resolver + .Setup(x => x.Resolve( + It.IsAny())) + .Returns(result); + + return resolver; + } + + private static AuthSessionId CreateSessionId( + string value) + { + AuthSessionId.TryCreate( + value, + out var id) + .Should() + .BeTrue(); + + return id; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/CookieSessionIdResolverTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/CookieSessionIdResolverTests.cs new file mode 100644 index 00000000..60a4005a --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/CookieSessionIdResolverTests.cs @@ -0,0 +1,112 @@ +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class CookieSessionIdResolverTests +{ + private const string ValidSessionId = + "cookie-session-0000000000000000000000001"; + + [Fact] + public void Name_ShouldBeCookie() + { + var sut = CreateSut(); + + sut.Name.Should().Be("cookie"); + } + + [Fact] + public void Resolve_WhenCookieMissing_ShouldReturnNull() + { + var sut = CreateSut(); + var context = new DefaultHttpContext(); + + var result = sut.Resolve(context); + + result.Should().BeNull(); + } + + [Fact] + public void Resolve_WhenCookieIsEmpty_ShouldReturnNull() + { + var sut = CreateSut(); + + var context = new DefaultHttpContext(); + + context.Request.Headers.Cookie = + "uauth_session="; + + var result = sut.Resolve(context); + + result.Should().BeNull(); + } + + [Fact] + public void Resolve_WhenCookieContainsInvalidSessionId_ShouldReturnNull() + { + var sut = CreateSut(); + + var context = new DefaultHttpContext(); + + context.Request.Headers.Cookie = + "uauth_session=invalid"; + + var result = sut.Resolve(context); + + result.Should().BeNull(); + } + + [Fact] + public void Resolve_WhenCookieContainsValidSessionId_ShouldReturnSessionId() + { + var sut = CreateSut(); + + var context = new DefaultHttpContext(); + + context.Request.Headers.Cookie = + $"uauth_session={ValidSessionId}"; + + var result = sut.Resolve(context); + + result.Should().NotBeNull(); + result!.Value.ToString().Should().Be(ValidSessionId); + } + + [Fact] + public void Resolve_ShouldUseConfiguredCookieName() + { + var options = TestServerOptions.Default(); + + options.Cookie.Session.Name = + "custom_session"; + + var sut = + new CookieSessionIdResolver( + Options.Create(options)); + + var context = new DefaultHttpContext(); + + context.Request.Headers.Cookie = + $"uauth_session=ignored-session-00000000000000000001; custom_session={ValidSessionId}"; + + var result = sut.Resolve(context); + + result.Should().NotBeNull(); + result!.Value.ToString().Should().Be(ValidSessionId); + } + + private static CookieSessionIdResolver CreateSut() + { + var options = TestServerOptions.Default(); + + options.Cookie.Session.Name = + "uauth_session"; + + return new CookieSessionIdResolver( + Options.Create(options)); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/HeaderSessionIdResolverTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/HeaderSessionIdResolverTests.cs new file mode 100644 index 00000000..ce0faef7 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/HeaderSessionIdResolverTests.cs @@ -0,0 +1,111 @@ +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class HeaderSessionIdResolverTests +{ + private const string ValidSessionId = + "header-session-0000000000000000000000001"; + + [Fact] + public void Name_ShouldBeHeader() + { + var sut = CreateSut(); + + sut.Name.Should().Be("header"); + } + + [Fact] + public void Resolve_WhenConfiguredHeaderMissing_ShouldReturnNull() + { + var sut = CreateSut(); + var context = new DefaultHttpContext(); + + var result = sut.Resolve(context); + + result.Should().BeNull(); + } + + [Fact] + public void Resolve_WhenHeaderIsEmpty_ShouldReturnNull() + { + var sut = CreateSut(); + var context = new DefaultHttpContext(); + + context.Request.Headers["X-UAuth-Session"] = ""; + + var result = sut.Resolve(context); + + result.Should().BeNull(); + } + + [Fact] + public void Resolve_WhenHeaderContainsInvalidSessionId_ShouldReturnNull() + { + var sut = CreateSut(); + var context = new DefaultHttpContext(); + + context.Request.Headers["X-UAuth-Session"] = "invalid"; + + var result = sut.Resolve(context); + + result.Should().BeNull(); + } + + [Fact] + public void Resolve_WhenHeaderContainsValidSessionId_ShouldReturnSessionId() + { + var sut = CreateSut(); + var context = new DefaultHttpContext(); + + context.Request.Headers["X-UAuth-Session"] = + ValidSessionId; + + var result = sut.Resolve(context); + + result.Should().NotBeNull(); + result!.Value.ToString().Should().Be(ValidSessionId); + } + + [Fact] + public void Resolve_ShouldUseConfiguredHeaderName() + { + var options = TestServerOptions.Default(); + + options.SessionResolution.HeaderName = + "X-Custom-Session"; + + var sut = + new HeaderSessionIdResolver( + Options.Create(options)); + + var context = new DefaultHttpContext(); + + context.Request.Headers["X-UAuth-Session"] = + "wrong-header-session-000000000000000000001"; + + context.Request.Headers["X-Custom-Session"] = + ValidSessionId; + + var result = sut.Resolve(context); + + result.Should().NotBeNull(); + result!.Value.ToString().Should().Be(ValidSessionId); + } + + private static HeaderSessionIdResolver CreateSut() + { + var options = TestServerOptions.Default(); + + options.SessionResolution.HeaderName = + "X-UAuth-Session"; + + return new HeaderSessionIdResolver( + Options.Create(options)); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/HttpContextSessionExtensionsTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/HttpContextSessionExtensionsTests.cs new file mode 100644 index 00000000..ff544812 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/HttpContextSessionExtensionsTests.cs @@ -0,0 +1,110 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Extensions; +using FluentAssertions; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class HttpContextSessionExtensionsTests +{ + [Fact] + public void GetSessionContext_WhenSessionContextExists_ShouldReturnSameInstance() + { + var httpContext = + new DefaultHttpContext(); + + var sessionId = + CreateSessionId( + "session-context-000000000000000000000001"); + + var tenant = + TenantKey.FromExternal("tenant-a"); + + var sessionContext = + SessionContext.FromSessionId( + sessionId, + tenant); + + httpContext.Items[ + UAuthConstants.HttpItems.SessionContext + ] = sessionContext; + + var result = + httpContext.GetSessionContext(); + + result.Should() + .BeSameAs(sessionContext); + + result.IsAnonymous.Should() + .BeFalse(); + + result.SessionId.Should() + .Be(sessionId); + + result.Tenant.Should() + .Be(tenant); + } + + [Fact] + public void GetSessionContext_WhenSessionContextDoesNotExist_ShouldReturnAnonymousContext() + { + var httpContext = + new DefaultHttpContext(); + + var result = + httpContext.GetSessionContext(); + + result.Should() + .NotBeNull(); + + result.IsAnonymous.Should() + .BeTrue(); + + result.SessionId.Should() + .BeNull(); + + result.Tenant.Should() + .BeNull(); + } + + [Fact] + public void GetSessionContext_WhenStoredValueHasWrongType_ShouldReturnAnonymousContext() + { + var httpContext = + new DefaultHttpContext(); + + httpContext.Items[ + UAuthConstants.HttpItems.SessionContext + ] = "not-a-session-context"; + + var result = + httpContext.GetSessionContext(); + + result.Should() + .NotBeNull(); + + result.IsAnonymous.Should() + .BeTrue(); + + result.SessionId.Should() + .BeNull(); + + result.Tenant.Should() + .BeNull(); + } + + private static AuthSessionId CreateSessionId( + string value) + { + AuthSessionId.TryCreate( + value, + out var sessionId) + .Should() + .BeTrue(); + + return sessionId; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/QuerySessionIdResolverTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/QuerySessionIdResolverTests.cs new file mode 100644 index 00000000..8c2e6cc3 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/QuerySessionIdResolverTests.cs @@ -0,0 +1,115 @@ +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class QuerySessionIdResolverTests +{ + private const string ValidSessionId = + "query-session-00000000000000000000000001"; + + [Fact] + public void Name_ShouldBeQuery() + { + var sut = CreateSut(); + + sut.Name.Should().Be("query"); + } + + [Fact] + public void Resolve_WhenQueryParameterMissing_ShouldReturnNull() + { + var sut = CreateSut(); + var context = new DefaultHttpContext(); + + var result = sut.Resolve(context); + + result.Should().BeNull(); + } + + [Fact] + public void Resolve_WhenQueryParameterIsEmpty_ShouldReturnNull() + { + var sut = CreateSut(); + + var context = CreateContext( + "?uauth_session="); + + var result = sut.Resolve(context); + + result.Should().BeNull(); + } + + [Fact] + public void Resolve_WhenQueryParameterIsInvalid_ShouldReturnNull() + { + var sut = CreateSut(); + + var context = CreateContext( + "?uauth_session=invalid"); + + var result = sut.Resolve(context); + + result.Should().BeNull(); + } + + [Fact] + public void Resolve_WhenQueryParameterIsValid_ShouldReturnSessionId() + { + var sut = CreateSut(); + + var context = CreateContext( + $"?uauth_session={ValidSessionId}"); + + var result = sut.Resolve(context); + + result.Should().NotBeNull(); + result!.Value.ToString().Should().Be(ValidSessionId); + } + + [Fact] + public void Resolve_ShouldUseConfiguredQueryParameterName() + { + var options = TestServerOptions.Default(); + + options.SessionResolution.QueryParameterName = + "custom_session"; + + var sut = + new QuerySessionIdResolver( + Options.Create(options)); + + var context = CreateContext( + $"?uauth_session=ignored-session-00000000000000000001&custom_session={ValidSessionId}"); + + var result = sut.Resolve(context); + + result.Should().NotBeNull(); + result!.Value.ToString().Should().Be(ValidSessionId); + } + + private static QuerySessionIdResolver CreateSut() + { + var options = TestServerOptions.Default(); + + options.SessionResolution.QueryParameterName = + "uauth_session"; + + return new QuerySessionIdResolver( + Options.Create(options)); + } + + private static DefaultHttpContext CreateContext( + string queryString) + { + var context = new DefaultHttpContext(); + + context.Request.QueryString = + new QueryString(queryString); + + return context; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionContextAccessorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionContextAccessorTests.cs new file mode 100644 index 00000000..219f50c4 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionContextAccessorTests.cs @@ -0,0 +1,104 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class SessionContextAccessorTests +{ + [Fact] + public void Current_WhenHttpContextDoesNotExist_ShouldReturnNull() + { + var httpContextAccessor = + new Mock(); + + httpContextAccessor + .SetupGet(x => x.HttpContext) + .Returns((HttpContext?)null); + + var sut = + new SessionContextAccessor( + httpContextAccessor.Object); + + var result = sut.Current; + + result.Should().BeNull(); + } + + [Fact] + public void Current_WhenSessionContextExists_ShouldReturnSameInstance() + { + var httpContext = new DefaultHttpContext(); + + var sessionContext = + SessionContext.Anonymous(); + + httpContext.Items[ + UAuthConstants.HttpItems.SessionContext + ] = sessionContext; + + var httpContextAccessor = + new Mock(); + + httpContextAccessor + .SetupGet(x => x.HttpContext) + .Returns(httpContext); + + var sut = + new SessionContextAccessor( + httpContextAccessor.Object); + + var result = sut.Current; + + result.Should().BeSameAs(sessionContext); + } + + [Fact] + public void Current_WhenSessionContextDoesNotExist_ShouldReturnNull() + { + var httpContext = new DefaultHttpContext(); + + var httpContextAccessor = + new Mock(); + + httpContextAccessor + .SetupGet(x => x.HttpContext) + .Returns(httpContext); + + var sut = + new SessionContextAccessor( + httpContextAccessor.Object); + + var result = sut.Current; + + result.Should().BeNull(); + } + + [Fact] + public void Current_WhenStoredValueHasWrongType_ShouldReturnNull() + { + var httpContext = new DefaultHttpContext(); + + httpContext.Items[ + UAuthConstants.HttpItems.SessionContext + ] = "not-a-session-context"; + + var httpContextAccessor = + new Mock(); + + httpContextAccessor + .SetupGet(x => x.HttpContext) + .Returns(httpContext); + + var sut = + new SessionContextAccessor( + httpContextAccessor.Object); + + var result = sut.Current; + + result.Should().BeNull(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserCreateValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserCreateValidatorTests.cs new file mode 100644 index 00000000..5af89c05 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserCreateValidatorTests.cs @@ -0,0 +1,499 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Users.Contracts; +using FluentAssertions; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class UserCreateValidatorIdentifierTests +{ + [Fact] + public async Task ValidateAsync_WhenNoIdentifierProvided_ShouldReturnIdentifierRequired() + { + var fixture = CreateFixture(); + + var request = new CreateUserRequest + { + UserName = null, + Email = null, + Phone = null + }; + + var result = await fixture.Sut.ValidateAsync( + fixture.Context, + request); + + result.IsValid.Should().BeFalse(); + + result.Errors.Should().ContainSingle(x => + x.Code == "identifier_required"); + + fixture.IdentifierValidator.Verify( + x => x.ValidateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + + fixture.IdentifierAvailability.Verify( + x => x.CheckAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Theory] + [InlineData(UserIdentifierType.Username)] + [InlineData(UserIdentifierType.Email)] + [InlineData(UserIdentifierType.Phone)] + public async Task ValidateAsync_WhenIdentifierIsInvalid_ShouldNotCheckAvailability( + UserIdentifierType type) + { + var fixture = CreateFixture(); + + fixture.IdentifierValidator + .Setup(x => x.ValidateAsync( + fixture.Context, + It.Is(i => + i.Type == type), + It.IsAny())) + .ReturnsAsync( + UserIdentifierValidationResult.Failed( + new[] + { + new UAuthValidationError( + "identifier_invalid") + })); + + var result = await fixture.Sut.ValidateAsync( + fixture.Context, + CreateRequest(type, "invalid-value")); + + result.IsValid.Should().BeFalse(); + + result.Errors.Should().Contain(x => + x.Code == "identifier_invalid"); + + fixture.IdentifierAvailability.Verify( + x => x.CheckAsync( + It.IsAny(), + It.Is( + r => r.Type == type), + It.IsAny()), + Times.Never); + } + + [Theory] + [InlineData( + UserIdentifierType.Username, + "username_unavailable")] + [InlineData( + UserIdentifierType.Email, + "email_unavailable")] + [InlineData( + UserIdentifierType.Phone, + "phone_unavailable")] + public async Task ValidateAsync_WhenIdentifierIsUnavailable_ShouldReturnExpectedError( + UserIdentifierType type, + string expectedError) + { + var fixture = CreateFixture(); + + SetupValidIdentifier( + fixture, + type); + + fixture.IdentifierAvailability + .Setup(x => x.CheckAsync( + fixture.Context, + It.Is(r => + r.Type == type && + r.Value == "value"), + It.IsAny())) + .ReturnsAsync( + UserIdentifierAvailabilityResult.Unavailable( + "value")); + + var result = await fixture.Sut.ValidateAsync( + fixture.Context, + CreateRequest(type, "value")); + + result.IsValid.Should().BeFalse(); + + result.Errors.Should().ContainSingle(x => + x.Code == expectedError); + } + + [Theory] + [InlineData(UserIdentifierType.Username)] + [InlineData(UserIdentifierType.Email)] + [InlineData(UserIdentifierType.Phone)] + public async Task ValidateAsync_WhenIdentifierIsValidAndAvailable_ShouldSucceed( + UserIdentifierType type) + { + var fixture = CreateFixture(); + + SetupValidIdentifier( + fixture, + type); + + fixture.IdentifierAvailability + .Setup(x => x.CheckAsync( + fixture.Context, + It.Is(r => + r.Type == type && + r.Value == "value"), + It.IsAny())) + .ReturnsAsync( + UserIdentifierAvailabilityResult.Available( + "value")); + + var result = await fixture.Sut.ValidateAsync( + fixture.Context, + CreateRequest(type, "value")); + + result.IsValid.Should().BeTrue(); + result.Errors.Should().BeEmpty(); + } + + [Fact] + public async Task ValidateAsync_WhenAllIdentifiersProvided_ShouldValidateAllIdentifiers() + { + var fixture = CreateFixture(); + + fixture.IdentifierValidator + .Setup(x => x.ValidateAsync( + fixture.Context, + It.IsAny(), + It.IsAny())) + .ReturnsAsync( + UserIdentifierValidationResult.Success()); + + fixture.IdentifierAvailability + .Setup(x => x.CheckAsync( + fixture.Context, + It.IsAny(), + It.IsAny())) + .ReturnsAsync( + UserIdentifierAvailabilityResult.Available( + "normalized")); + + var result = await fixture.Sut.ValidateAsync( + fixture.Context, + new CreateUserRequest + { + UserName = "john", + Email = "john@example.com", + Phone = "1234567890" + }); + + result.IsValid.Should().BeTrue(); + + fixture.IdentifierValidator.Verify( + x => x.ValidateAsync( + fixture.Context, + It.IsAny(), + It.IsAny()), + Times.Exactly(3)); + + fixture.IdentifierAvailability.Verify( + x => x.CheckAsync( + fixture.Context, + It.IsAny(), + It.IsAny()), + Times.Exactly(3)); + } + + [Fact] + public async Task ValidateAsync_WhenAllIdentifiersProvided_ShouldUseCorrectTypesAndValues() + { + var fixture = CreateFixture(); + + fixture.IdentifierValidator + .Setup(x => x.ValidateAsync( + fixture.Context, + It.IsAny(), + It.IsAny())) + .ReturnsAsync( + UserIdentifierValidationResult.Success()); + + fixture.IdentifierAvailability + .Setup(x => x.CheckAsync( + fixture.Context, + It.IsAny(), + It.IsAny())) + .ReturnsAsync( + UserIdentifierAvailabilityResult.Available( + "normalized")); + + await fixture.Sut.ValidateAsync( + fixture.Context, + new CreateUserRequest + { + UserName = "john", + Email = "john@example.com", + Phone = "1234567890" + }); + + fixture.IdentifierValidator.Verify( + x => x.ValidateAsync( + fixture.Context, + It.Is(i => + i.Type == UserIdentifierType.Username && + i.Value == "john"), + It.IsAny()), + Times.Once); + + fixture.IdentifierValidator.Verify( + x => x.ValidateAsync( + fixture.Context, + It.Is(i => + i.Type == UserIdentifierType.Email && + i.Value == "john@example.com"), + It.IsAny()), + Times.Once); + + fixture.IdentifierValidator.Verify( + x => x.ValidateAsync( + fixture.Context, + It.Is(i => + i.Type == UserIdentifierType.Phone && + i.Value == "1234567890"), + It.IsAny()), + Times.Once); + + fixture.IdentifierAvailability.Verify( + x => x.CheckAsync( + fixture.Context, + It.Is(r => + r.Type == UserIdentifierType.Username && + r.Value == "john"), + It.IsAny()), + Times.Once); + + fixture.IdentifierAvailability.Verify( + x => x.CheckAsync( + fixture.Context, + It.Is(r => + r.Type == UserIdentifierType.Email && + r.Value == "john@example.com"), + It.IsAny()), + Times.Once); + + fixture.IdentifierAvailability.Verify( + x => x.CheckAsync( + fixture.Context, + It.Is(r => + r.Type == UserIdentifierType.Phone && + r.Value == "1234567890"), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ValidateAsync_WhenOneIdentifierIsInvalid_ShouldContinueValidatingOtherIdentifiers() + { + var fixture = CreateFixture(); + + fixture.IdentifierValidator + .Setup(x => x.ValidateAsync( + fixture.Context, + It.Is(i => + i.Type == UserIdentifierType.Username), + It.IsAny())) + .ReturnsAsync( + UserIdentifierValidationResult.Failed( + new[] + { + new UAuthValidationError( + "username_invalid") + })); + + fixture.IdentifierValidator + .Setup(x => x.ValidateAsync( + fixture.Context, + It.Is(i => + i.Type == UserIdentifierType.Email), + It.IsAny())) + .ReturnsAsync( + UserIdentifierValidationResult.Success()); + + fixture.IdentifierAvailability + .Setup(x => x.CheckAsync( + fixture.Context, + It.Is(r => + r.Type == UserIdentifierType.Email), + It.IsAny())) + .ReturnsAsync( + UserIdentifierAvailabilityResult.Available( + "john@example.com")); + + var result = await fixture.Sut.ValidateAsync( + fixture.Context, + new CreateUserRequest + { + UserName = "invalid", + Email = "john@example.com" + }); + + result.IsValid.Should().BeFalse(); + + result.Errors.Should().Contain(x => + x.Code == "username_invalid"); + + fixture.IdentifierAvailability.Verify( + x => x.CheckAsync( + fixture.Context, + It.Is(r => + r.Type == UserIdentifierType.Username), + It.IsAny()), + Times.Never); + + fixture.IdentifierAvailability.Verify( + x => x.CheckAsync( + fixture.Context, + It.Is(r => + r.Type == UserIdentifierType.Email), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ValidateAsync_ShouldPropagateCancellationTokenToIdentifierServices() + { + var fixture = CreateFixture(); + + using var cts = + new CancellationTokenSource(); + + fixture.IdentifierValidator + .Setup(x => x.ValidateAsync( + fixture.Context, + It.IsAny(), + cts.Token)) + .ReturnsAsync( + UserIdentifierValidationResult.Success()); + + fixture.IdentifierAvailability + .Setup(x => x.CheckAsync( + fixture.Context, + It.IsAny(), + cts.Token)) + .ReturnsAsync( + UserIdentifierAvailabilityResult.Available( + "john")); + + await fixture.Sut.ValidateAsync( + fixture.Context, + new CreateUserRequest + { + UserName = "john" + }, + cts.Token); + + fixture.IdentifierValidator.Verify( + x => x.ValidateAsync( + fixture.Context, + It.IsAny(), + cts.Token), + Times.Once); + + fixture.IdentifierAvailability.Verify( + x => x.CheckAsync( + fixture.Context, + It.IsAny(), + cts.Token), + Times.Once); + } + + private static void SetupValidIdentifier( + Fixture fixture, + UserIdentifierType type) + { + fixture.IdentifierValidator + .Setup(x => x.ValidateAsync( + fixture.Context, + It.Is(i => + i.Type == type), + It.IsAny())) + .ReturnsAsync( + UserIdentifierValidationResult.Success()); + } + + private static CreateUserRequest CreateRequest( + UserIdentifierType type, + string value) + { + return type switch + { + UserIdentifierType.Username => + new CreateUserRequest + { + UserName = value + }, + + UserIdentifierType.Email => + new CreateUserRequest + { + Email = value + }, + + UserIdentifierType.Phone => + new CreateUserRequest + { + Phone = value + }, + + _ => throw new ArgumentOutOfRangeException( + nameof(type)) + }; + } + + private static Fixture CreateFixture() + { + var identifierValidator = + new Mock(); + + var identifierAvailability = + new Mock(); + + var profileValidator = + new Mock(); + + profileValidator + .Setup(x => x.ValidateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync( + UserProfileValidationResult.Success()); + + var context = + TestAccessContext.WithAction("users.create"); + + var sut = + new UserCreateValidator( + identifierValidator.Object, + identifierAvailability.Object, + profileValidator.Object); + + return new Fixture( + sut, + context, + identifierValidator, + identifierAvailability, + profileValidator); + } + + private sealed record Fixture( + UserCreateValidator Sut, + AccessContext Context, + Mock IdentifierValidator, + Mock IdentifierAvailability, + Mock ProfileValidator); +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierValidationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierValidationTests.cs new file mode 100644 index 00000000..52a4fec9 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierValidationTests.cs @@ -0,0 +1,260 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Users.Contracts; +using FluentAssertions; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class UserIdentifierValidatorTests +{ + [Fact] + public async Task ValidateAsync_WhenIdentifierIsEmpty_ShouldReturnIdentifierEmpty() + { + var sut = CreateSut(); + + var result = await sut.ValidateAsync( + null!, + new UserIdentifierInfo + { + Type = UserIdentifierType.Username, + Value = " " + }); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainSingle(x => + x.Code == "identifier_empty"); + } + + [Fact] + public async Task ValidateAsync_ShouldTrimIdentifierBeforeValidation() + { + var sut = CreateSut(); + + var identifier = new UserIdentifierInfo + { + Type = UserIdentifierType.Username, + Value = " valid_user " + }; + + var result = await sut.ValidateAsync(null!, identifier); + + result.IsValid.Should().BeTrue(); + identifier.Value.Should().Be("valid_user"); + } + + [Fact] + public async Task ValidateAsync_WhenUsernameIsTooShort_ShouldReturnUsernameTooShort() + { + var sut = CreateSut(options => + { + options.IdentifierValidation.UserName.MinLength = 5; + }); + + var result = await sut.ValidateAsync( + null!, + new UserIdentifierInfo + { + Type = UserIdentifierType.Username, + Value = "abc" + }); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(x => + x.Code == "username_too_short"); + } + + [Fact] + public async Task ValidateAsync_WhenUsernameIsTooLong_ShouldReturnUsernameTooLong() + { + var sut = CreateSut(options => + { + options.IdentifierValidation.UserName.MaxLength = 5; + }); + + var result = await sut.ValidateAsync( + null!, + new UserIdentifierInfo + { + Type = UserIdentifierType.Username, + Value = "abcdef" + }); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(x => + x.Code == "username_too_long"); + } + + [Fact] + public async Task ValidateAsync_WhenUsernameDoesNotMatchAllowedRegex_ShouldReturnInvalidFormat() + { + var sut = CreateSut(options => + { + options.IdentifierValidation.UserName.AllowedRegex = + "^[a-z]+$"; + }); + + var result = await sut.ValidateAsync( + null!, + new UserIdentifierInfo + { + Type = UserIdentifierType.Username, + Value = "user123" + }); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(x => + x.Code == "username_invalid_format"); + } + + [Fact] + public async Task ValidateAsync_WhenUsernameValidationIsDisabled_ShouldSkipUsernameRules() + { + var sut = CreateSut(options => + { + options.IdentifierValidation.UserName.Enabled = false; + options.IdentifierValidation.UserName.MinLength = 100; + }); + + var result = await sut.ValidateAsync( + null!, + new UserIdentifierInfo + { + Type = UserIdentifierType.Username, + Value = "a" + }); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public async Task ValidateAsync_WhenEmailDoesNotContainAt_ShouldReturnInvalidFormat() + { + var sut = CreateSut(); + + var result = await sut.ValidateAsync( + null!, + new UserIdentifierInfo + { + Type = UserIdentifierType.Email, + Value = "not-an-email" + }); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(x => + x.Code == "email_invalid_format"); + } + + [Fact] + public async Task ValidateAsync_WhenEmailIsTooShort_ShouldReturnEmailTooShort() + { + var sut = CreateSut(options => + { + options.IdentifierValidation.Email.MinLength = 10; + }); + + var result = await sut.ValidateAsync( + null!, + new UserIdentifierInfo + { + Type = UserIdentifierType.Email, + Value = "a@b.co" + }); + + result.Errors.Should().Contain(x => + x.Code == "email_too_short"); + } + + [Fact] + public async Task ValidateAsync_WhenEmailIsTooLong_ShouldReturnEmailTooLong() + { + var sut = CreateSut(options => + { + options.IdentifierValidation.Email.MaxLength = 5; + }); + + var result = await sut.ValidateAsync( + null!, + new UserIdentifierInfo + { + Type = UserIdentifierType.Email, + Value = "abc@example.com" + }); + + result.Errors.Should().Contain(x => + x.Code == "email_too_long"); + } + + [Fact] + public async Task ValidateAsync_WhenPhoneIsTooShort_ShouldReturnPhoneTooShort() + { + var sut = CreateSut(options => + { + options.IdentifierValidation.Phone.MinLength = 10; + }); + + var result = await sut.ValidateAsync( + null!, + new UserIdentifierInfo + { + Type = UserIdentifierType.Phone, + Value = "123" + }); + + result.Errors.Should().Contain(x => + x.Code == "phone_too_short"); + } + + [Fact] + public async Task ValidateAsync_WhenPhoneIsTooLong_ShouldReturnPhoneTooLong() + { + var sut = CreateSut(options => + { + options.IdentifierValidation.Phone.MaxLength = 5; + }); + + var result = await sut.ValidateAsync( + null!, + new UserIdentifierInfo + { + Type = UserIdentifierType.Phone, + Value = "123456789" + }); + + result.Errors.Should().Contain(x => + x.Code == "phone_too_long"); + } + + [Fact] + public async Task ValidateAsync_WhenCancellationRequested_ShouldThrow() + { + var sut = CreateSut(); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => sut.ValidateAsync( + null!, + new UserIdentifierInfo + { + Type = UserIdentifierType.Username, + Value = "valid_user" + }, + cts.Token); + + await act.Should() + .ThrowAsync(); + } + + private static UserIdentifierValidator CreateSut( + Action? configure = null) + { + var options = new UAuthServerOptions(); + + configure?.Invoke(options); + + return new UserIdentifierValidator( + Options.Create(options)); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserProfileValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserProfileValidatorTests.cs new file mode 100644 index 00000000..6a2a0910 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserProfileValidatorTests.cs @@ -0,0 +1,39 @@ +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Users.Contracts; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class UserProfileValidatorTests +{ + [Fact] + public async Task ValidateAsync_CurrentImplementation_ShouldSucceed() + { + var sut = new UserProfileValidator(); + + var result = await sut.ValidateAsync( + null!, + new UserProfileInfo()); + + result.IsValid.Should().BeTrue(); + result.Errors.Should().BeEmpty(); + } + + [Fact] + public async Task ValidateAsync_WhenCancellationRequested_ShouldThrow() + { + var sut = new UserProfileValidator(); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => sut.ValidateAsync( + null!, + new UserProfileInfo(), + cts.Token); + + await act.Should() + .ThrowAsync(); + } +} From b30b7ae873c3b53fca41793f92fe7db5232a8f9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Wed, 7 Oct 2026 01:13:44 +0300 Subject: [PATCH 16/16] Core Project Cleanup --- .github/PULL_REQUEST_TEMPLATE.md | 2 - .github/codecov.yml | 8 +- README.md | 2 +- ROADMAP.md | 57 +------- .../Abstractions/Validators/IJwtValidator.cs | 20 +-- .../Session/Dtos/AuthSnapshotInfo.cs | 8 -- .../Contracts/Session/Dtos/ClaimsInfo.cs | 10 -- .../Contracts/Session/Dtos/IdentityInfo.cs | 10 -- .../Session/ResolvedRefreshSession.cs | 56 ++++---- .../Contracts/Token/PrimaryToken.cs | 28 ++-- .../Contracts/Token/TokenRefreshContext.cs | 10 -- .../Contracts/Token/TokenValidationResult.cs | 126 +++++++++--------- .../User/UserAuthenticationResult.cs | 36 ++--- .../Contracts/User/UserContext.cs | 18 +-- 14 files changed, 150 insertions(+), 241 deletions(-) delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/AuthSnapshotInfo.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/ClaimsInfo.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/IdentityInfo.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenRefreshContext.cs diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 39c4c0a7..51252ee1 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,5 +1,3 @@ -# 🚀 Pull Request - diff --git a/.github/codecov.yml b/.github/codecov.yml index c52c4b6c..d27f31d0 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -5,9 +5,9 @@ coverage: status: project: default: - target: 70% - threshold: 0% + target: 80% + threshold: 2% patch: default: - target: 50% - threshold: 0% + target: 80% + threshold: 5% diff --git a/README.md b/README.md index 9e7a8215..ac155657 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ UltimateAuth is an open-source auth framework with platform-level capabilities t | Phase | Version | Scope | Status | Release Date | | ----------------------- | ------------- | ----------------------------------------- | -------------- | ------------ | | First Preview | 0.1.0-preview | "Stable" Preview Core | ✅ Completed | 07.04.2026 | -| First Release* | 0.1.0 | Fully Documented & Quality Tested | ✅ Completed | 08.10.2026 | +| First Release* | 0.1.0 | Documented & Quality Tested | ✅ Completed | 08.10.2026 | | Product Expansion | 0.2.0 | Full Auth Modes | 🟡 In Progress | Q4 2026 | | Security Expansion | 0.3.0 | MFA, Reauth, Rate Limiting | 🟡 In Progress | Q4 2026 | | Infrastructure Expansion| 0.4.0 | Redis, Distributed Cache, Password Hasher | 🔜 Planned | Q1 2027 | diff --git a/ROADMAP.md b/ROADMAP.md index a35c79a6..323f459f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,61 +1,10 @@ # UltimateAuth Roadmap -This document outlines the planned development phases of UltimateAuth. -Dates are targets and may evolve based on community feedback and real-world usage. +> The project roadmap is actively maintained as a GitHub issue: ---- - -## 🟣 Q1 2026 — Preview (v 0.1.0) -The first public preview of UltimateAuth: -- Core session-based auth engine -- Secure opaque SessionId tokens -- Basic login / logout / refresh and PKCE flows -- Blazor & MAUI client foundations -- Initial server abstractions and extension points -- Early documentation and samples +👉 https://github.com/CodeBeamOrg/UltimateAuth/issues/8 --- -## 🟣 Q2 2026 — Stable Feature Release -Focus on stabilization, developer experience, and broader platform support: -- Hardened session lifecycle -- Expanded testing and validation -- Improved client SDK flows -- ASP.NET Core middleware polishing -- More complete documentation -- First public samples for real applications - ---- - -## 🟣 Q3 2026 — Version 1.0.0 (General Availability) -UltimateAuth reaches production-readiness: -- API surface finalized and locked -- Full PKCE/OAuth-style flow support -- Unified architecture across client & server -- Performance improvements -- Security review & hardening -- Real-world feedback applied -- Long-term support model established - ---- - -## 🟣 Q4 2026 — .NET 11 Alignment (v11.x.x) -UltimateAuth aligns its versioning and platform features with .NET 11: -- Framework-wide modernization updates -- Future-proof API adjustments -- Compatibility refinements - ---- - -## 🟣 Beyond 2026 — Long-Term Vision -- Advanced OAuth integrations -- External identity provider modules -- Enhanced build-in security features (MFA, biometrics) -- Extended session storage providers -- Developer tooling & templates -- Deep Blazor/Maui experience enhancements -- Distributed session management options - ---- +UltimateAuth is developed openly and shaped by community feedback. -UltimateAuth is developed openly and shaped by community feedback. Thank you for helping us build the next-generation authentication framework for .NET. diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IJwtValidator.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IJwtValidator.cs index 404422a8..57ec16f2 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IJwtValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IJwtValidator.cs @@ -1,12 +1,12 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +//using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Core.Abstractions; +//namespace CodeBeam.UltimateAuth.Core.Abstractions; -/// -/// Validates access tokens (JWT or opaque) and resolves -/// the authenticated user context. -/// -public interface IJwtValidator -{ - Task> ValidateAsync(string token, CancellationToken ct = default); -} +///// +///// Validates access tokens (JWT or opaque) and resolves +///// the authenticated user context. +///// +//public interface IJwtValidator +//{ +// Task> ValidateAsync(string token, CancellationToken ct = default); +//} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/AuthSnapshotInfo.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/AuthSnapshotInfo.cs deleted file mode 100644 index 79654c8c..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/AuthSnapshotInfo.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts; - -public sealed class AuthSnapshotInfo -{ - public IdentityInfo? Identity { get; set; } - - public ClaimsInfo? Claims { get; set; } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/ClaimsInfo.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/ClaimsInfo.cs deleted file mode 100644 index 005895b3..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/ClaimsInfo.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts; - -public sealed class ClaimsInfo -{ - public Dictionary Claims { get; set; } = new(); - - public string[] Roles { get; set; } = Array.Empty(); - - public string[] Permissions { get; set; } = Array.Empty(); -} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/IdentityInfo.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/IdentityInfo.cs deleted file mode 100644 index c7488e64..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/IdentityInfo.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts; - -public sealed class IdentityInfo -{ - public string Tenant { get; set; } = default!; - - public string? UserKey { get; set; } - - public DateTimeOffset? AuthenticatedAt { get; set; } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs index 42c51971..0cf300f6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs @@ -1,35 +1,35 @@ -using CodeBeam.UltimateAuth.Core.Domain; +//using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Contracts; +//namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed record ResolvedRefreshSession -{ - public bool IsValid { get; init; } - public bool IsReuseDetected { get; init; } +//public sealed record ResolvedRefreshSession +//{ +// public bool IsValid { get; init; } +// public bool IsReuseDetected { get; init; } - public UAuthSession? Session { get; init; } - public UAuthSessionChain? Chain { get; init; } +// public UAuthSession? Session { get; init; } +// public UAuthSessionChain? Chain { get; init; } - private ResolvedRefreshSession() { } +// private ResolvedRefreshSession() { } - public static ResolvedRefreshSession Invalid() - => new() - { - IsValid = false - }; +// public static ResolvedRefreshSession Invalid() +// => new() +// { +// IsValid = false +// }; - public static ResolvedRefreshSession Reused() - => new() - { - IsValid = false, - IsReuseDetected = true - }; +// public static ResolvedRefreshSession Reused() +// => new() +// { +// IsValid = false, +// IsReuseDetected = true +// }; - public static ResolvedRefreshSession Valid(UAuthSession session, UAuthSessionChain chain) - => new() - { - IsValid = true, - Session = session, - Chain = chain - }; -} +// public static ResolvedRefreshSession Valid(UAuthSession session, UAuthSessionChain chain) +// => new() +// { +// IsValid = true, +// Session = session, +// Chain = chain +// }; +//} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs index be3a120c..368e4a4b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs @@ -1,19 +1,19 @@ -using CodeBeam.UltimateAuth.Core.Domain; +//using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Contracts; +//namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed record PrimaryToken -{ - public PrimaryTokenKind Kind { get; } - public string Value { get; } +//public sealed record PrimaryToken +//{ +// public PrimaryTokenKind Kind { get; } +// public string Value { get; } - private PrimaryToken(PrimaryTokenKind kind, string value) - { - Kind = kind; - Value = value; - } +// private PrimaryToken(PrimaryTokenKind kind, string value) +// { +// Kind = kind; +// Value = value; +// } - public static PrimaryToken FromSession(AuthSessionId sessionId) => new(PrimaryTokenKind.Session, sessionId.ToString()); +// public static PrimaryToken FromSession(AuthSessionId sessionId) => new(PrimaryTokenKind.Session, sessionId.ToString()); - public static PrimaryToken FromAccessToken(AccessToken token) => new(PrimaryTokenKind.AccessToken, token.Token); -} +// public static PrimaryToken FromAccessToken(AccessToken token) => new(PrimaryTokenKind.AccessToken, token.Token); +//} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenRefreshContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenRefreshContext.cs deleted file mode 100644 index 2796946b..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenRefreshContext.cs +++ /dev/null @@ -1,10 +0,0 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Core.Contracts; - -public sealed record TokenRefreshContext -{ - public TenantKey Tenant { get; init; } - - public string RefreshToken { get; init; } = default!; -} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs index 372fd4fd..c77b6a71 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs @@ -1,68 +1,68 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using System.Security.Claims; +//using CodeBeam.UltimateAuth.Core.Domain; +//using CodeBeam.UltimateAuth.Core.MultiTenancy; +//using System.Security.Claims; -namespace CodeBeam.UltimateAuth.Core.Contracts; +//namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed record TokenValidationResult -{ - public bool IsValid { get; init; } - public TokenFormat Format { get; init; } - public TenantKey? Tenant { get; init; } - public TUserId? UserId { get; init; } - public AuthSessionId? SessionId { get; init; } - public IReadOnlyCollection Claims { get; init; } = Array.Empty(); - public TokenInvalidReason? InvalidReason { get; init; } - public DateTimeOffset? ExpiresAt { get; set; } +//public sealed record TokenValidationResult +//{ +// public bool IsValid { get; init; } +// public TokenFormat Format { get; init; } +// public TenantKey? Tenant { get; init; } +// public TUserId? UserId { get; init; } +// public AuthSessionId? SessionId { get; init; } +// public IReadOnlyCollection Claims { get; init; } = Array.Empty(); +// public TokenInvalidReason? InvalidReason { get; init; } +// public DateTimeOffset? ExpiresAt { get; set; } - private TokenValidationResult( - bool isValid, - TokenFormat format, - TenantKey? tenant, - TUserId? userId, - AuthSessionId? sessionId, - IReadOnlyCollection? claims, - TokenInvalidReason? invalidReason, - DateTimeOffset? expiresAt - ) - { - IsValid = isValid; - Format = format; - Tenant = tenant; - UserId = userId; - SessionId = sessionId; - Claims = claims ?? Array.Empty(); - InvalidReason = invalidReason; - ExpiresAt = expiresAt; - } +// private TokenValidationResult( +// bool isValid, +// TokenFormat format, +// TenantKey? tenant, +// TUserId? userId, +// AuthSessionId? sessionId, +// IReadOnlyCollection? claims, +// TokenInvalidReason? invalidReason, +// DateTimeOffset? expiresAt +// ) +// { +// IsValid = isValid; +// Format = format; +// Tenant = tenant; +// UserId = userId; +// SessionId = sessionId; +// Claims = claims ?? Array.Empty(); +// InvalidReason = invalidReason; +// ExpiresAt = expiresAt; +// } - public static TokenValidationResult Valid( - TokenFormat format, - TenantKey tenant, - TUserId userId, - AuthSessionId? sessionId, - IReadOnlyCollection claims, - DateTimeOffset? expiresAt) - => new( - isValid: true, - format, - tenant, - userId, - sessionId, - claims, - invalidReason: null, - expiresAt - ); +// public static TokenValidationResult Valid( +// TokenFormat format, +// TenantKey tenant, +// TUserId userId, +// AuthSessionId? sessionId, +// IReadOnlyCollection claims, +// DateTimeOffset? expiresAt) +// => new( +// isValid: true, +// format, +// tenant, +// userId, +// sessionId, +// claims, +// invalidReason: null, +// expiresAt +// ); - public static TokenValidationResult Invalid(TokenFormat format, TokenInvalidReason reason) - => new( - isValid: false, - format: format, - tenant: null, - userId: default, - sessionId: null, - claims: null, - invalidReason: reason, - expiresAt: null - ); -} +// public static TokenValidationResult Invalid(TokenFormat format, TokenInvalidReason reason) +// => new( +// isValid: false, +// format: format, +// tenant: null, +// userId: default, +// sessionId: null, +// claims: null, +// invalidReason: reason, +// expiresAt: null +// ); +//} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs index a105c336..78bd7e1c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs @@ -1,25 +1,25 @@ -using CodeBeam.UltimateAuth.Core.Domain; +//using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Contracts; +//namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed class UserAuthenticationResult -{ - public bool Succeeded { get; init; } +//public sealed class UserAuthenticationResult +//{ +// public bool Succeeded { get; init; } - public TUserId? UserId { get; init; } +// public TUserId? UserId { get; init; } - public ClaimsSnapshot? Claims { get; init; } +// public ClaimsSnapshot? Claims { get; init; } - public bool RequiresMfa { get; init; } +// public bool RequiresMfa { get; init; } - public static UserAuthenticationResult Fail() => new() { Succeeded = false }; +// public static UserAuthenticationResult Fail() => new() { Succeeded = false }; - public static UserAuthenticationResult Success(TUserId userId, ClaimsSnapshot claims, bool requiresMfa = false) - => new() - { - Succeeded = true, - UserId = userId, - Claims = claims, - RequiresMfa = requiresMfa - }; -} +// public static UserAuthenticationResult Success(TUserId userId, ClaimsSnapshot claims, bool requiresMfa = false) +// => new() +// { +// Succeeded = true, +// UserId = userId, +// Claims = claims, +// RequiresMfa = requiresMfa +// }; +//} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs index 5a20021b..b7f9aa51 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs @@ -1,13 +1,13 @@ -using CodeBeam.UltimateAuth.Core.Domain; +//using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Contracts; +//namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed class UserContext -{ - public TUserId? UserId { get; init; } - public IAuthSubject? User { get; init; } +//public sealed class UserContext +//{ +// public TUserId? UserId { get; init; } +// public IAuthSubject? User { get; init; } - public bool IsAuthenticated => UserId is not null; +// public bool IsAuthenticated => UserId is not null; - public static UserContext Anonymous() => new(); -} +// public static UserContext Anonymous() => new(); +//}