diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs index c41aff30..7118a076 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -27,6 +27,7 @@ internal sealed class CredentialManagementService : ICredentialManagementService private readonly ISessionStoreFactory _sessionFactory; private readonly ICredentialResetNotifier _resetNotifier; private readonly ICredentialSessionPolicy _credentialSessionPolicy; + private readonly IUAuthAtomicExecutor _atomicExecutor; private readonly UAuthServerOptions _options; private readonly IClock _clock; @@ -42,6 +43,7 @@ public CredentialManagementService( ISessionStoreFactory sessionFactory, ICredentialResetNotifier resetNotifier, ICredentialSessionPolicy credentialSessionPolicy, + IUAuthAtomicExecutor atomicExecutor, IOptions options, IClock clock) { @@ -56,6 +58,7 @@ public CredentialManagementService( _sessionFactory = sessionFactory; _resetNotifier = resetNotifier; _credentialSessionPolicy = credentialSessionPolicy; + _atomicExecutor = atomicExecutor; _options = options.Value; _clock = clock; } @@ -125,48 +128,49 @@ public async Task ChangeSecretAsync(AccessContext contex { ct.ThrowIfCancellationRequested(); - var cmd = new AccessCommand(async innerCt => - { - var subjectUser = context.GetTargetUserKey(); - var now = _clock.UtcNow; + var cmd = new AccessCommand( + innerCt => _atomicExecutor.ExecuteAsync(async atomicCt => + { + var subjectUser = context.GetTargetUserKey(); + var now = _clock.UtcNow; - var store = _credentialsFactory.Create(context.ResourceTenant); - var credentials = await store.GetByUserAsync(subjectUser, innerCt); - var pwd = credentials.OfType().Where(c => c.Security.IsUsable(now)).FirstOrDefault(); + var store = _credentialsFactory.Create(context.ResourceTenant); + var credentials = await store.GetByUserAsync(subjectUser, atomicCt); + var pwd = credentials.OfType().Where(c => c.Security.IsUsable(now)).FirstOrDefault(); - if (pwd is null) - throw new UAuthNotFoundException("credential_not_found"); + if (pwd is null) + throw new UAuthNotFoundException("credential_not_found"); - if (pwd.UserKey != subjectUser) - throw new UAuthNotFoundException("credential_not_found"); + if (pwd.UserKey != subjectUser) + throw new UAuthNotFoundException("credential_not_found"); - if (string.IsNullOrWhiteSpace(request.NewSecret)) - throw new UAuthValidationException("new_secret_required"); + if (string.IsNullOrWhiteSpace(request.NewSecret)) + throw new UAuthValidationException("new_secret_required"); - if (context.IsSelfAction) - { - if (string.IsNullOrWhiteSpace(request.CurrentSecret)) - throw new UAuthValidationException("current_secret_required"); + if (context.IsSelfAction) + { + if (string.IsNullOrWhiteSpace(request.CurrentSecret)) + throw new UAuthValidationException("current_secret_required"); - if (!_hasher.Verify(pwd.SecretHash, request.CurrentSecret)) - throw new UAuthConflictException("invalid_credentials"); - } + if (!_hasher.Verify(pwd.SecretHash, request.CurrentSecret)) + throw new UAuthConflictException("invalid_credentials"); + } - if (_hasher.Verify(pwd.SecretHash, request.NewSecret)) - throw new UAuthValidationException("credential_secret_same"); + if (_hasher.Verify(pwd.SecretHash, request.NewSecret)) + throw new UAuthValidationException("credential_secret_same"); - var oldVersion = pwd.Version; - var newHash = _hasher.Hash(request.NewSecret); - var updated = pwd.ChangeSecret(newHash, now); - await store.SaveAsync(updated, oldVersion, innerCt); + var oldVersion = pwd.Version; + var newHash = _hasher.Hash(request.NewSecret); + var updated = pwd.ChangeSecret(newHash, now); + await store.SaveAsync(updated, oldVersion, atomicCt); - await ApplyCredentialSessionPolicyAsync(context.ResourceTenant, subjectUser, context.IsSelfAction - ? CredentialChangeReason.SelfChange - : CredentialChangeReason.AdminChange, - context.ActorChainId, now, innerCt); + await ApplyCredentialSessionPolicyAsync(context.ResourceTenant, subjectUser, context.IsSelfAction + ? CredentialChangeReason.SelfChange + : CredentialChangeReason.AdminChange, + context.ActorChainId, now, atomicCt); - return CredentialChangeResult.Success(pwd.Type); - }); + return CredentialChangeResult.Success(pwd.Type); + }, innerCt)); return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } @@ -372,18 +376,24 @@ await _authenticationSecurityManager.UpdateAsync( return CredentialActionResult.Success(); } - var version3 = state.SecurityVersion; - state = state.ConsumeReset(now); - await _authenticationSecurityManager.UpdateAsync(state, version3, innerCt); + return await _atomicExecutor.ExecuteAsync(async atomicCt => + { + var version = state.SecurityVersion; + var consumed = state.ConsumeReset(now); - var oldVersion = pwd.Version; - var newHash = _hasher.Hash(request.NewSecret); - var updated = pwd.ChangeSecret(newHash, now); + await _authenticationSecurityManager.UpdateAsync(consumed, version, atomicCt); - await store.SaveAsync(updated, oldVersion, innerCt); - await ApplyCredentialSessionPolicyAsync(context.ResourceTenant ,userKey, CredentialChangeReason.Recovery, actorChainId: null, now, innerCt); + var oldVersion = pwd.Version; + var newHash = _hasher.Hash(request.NewSecret); + var updated = pwd.ChangeSecret(newHash, now); - return CredentialActionResult.Success(); + await store.SaveAsync(updated, oldVersion, atomicCt); + + await ApplyCredentialSessionPolicyAsync(context.ResourceTenant, userKey, CredentialChangeReason.Recovery, actorChainId: null, + now, atomicCt); + + return CredentialActionResult.Success(); + }, innerCt); }); return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); @@ -456,7 +466,7 @@ async Task IUserCredentialsInternalService.DeleteInterna // HELPERS // ---------------------------------------- private async Task ApplyCredentialSessionPolicyAsync(TenantKey tenant, UserKey subjectUser, CredentialChangeReason reason, - SessionChainId? actorChainId, DateTimeOffset now, CancellationToken ct) + SessionChainId? actorChainId, DateTimeOffset now, CancellationToken ct) { var behavior = _credentialSessionPolicy.Resolve(reason); @@ -465,21 +475,22 @@ private async Task ApplyCredentialSessionPolicyAsync(TenantKey tenant, UserKey s var sessionStore = _sessionFactory.Create(tenant); - switch (behavior) + await sessionStore.ExecuteAsync(async innerCt => { - case CredentialSessionBehavior.PreserveCurrent - when reason == CredentialChangeReason.SelfChange && actorChainId is SessionChainId chainId: - await sessionStore.RevokeOtherChainsAsync(subjectUser, chainId, now, ct); - break; - - case CredentialSessionBehavior.PreserveCurrent: - case CredentialSessionBehavior.RevokeAll: + switch (behavior) + { + case CredentialSessionBehavior.PreserveCurrent when reason == CredentialChangeReason.SelfChange && actorChainId is SessionChainId chainId: + await sessionStore.RevokeOtherChainsAsync(subjectUser, chainId, now, innerCt); + break; - await sessionStore.RevokeAllChainsAsync(subjectUser, now, ct); - break; + case CredentialSessionBehavior.PreserveCurrent: + case CredentialSessionBehavior.RevokeAll: + await sessionStore.RevokeAllChainsAsync(subjectUser, now, innerCt); + break; - default: - throw new InvalidOperationException($"Unsupported credential session behavior: {behavior}"); - } + default: + throw new InvalidOperationException($"Unsupported credential session behavior: {behavior}"); + } + }, ct); } } diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/EfCoreUAuthAtomicExecutor.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/EfCoreUAuthAtomicExecutor.cs index 4e8149d8..b8428745 100644 --- a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/EfCoreUAuthAtomicExecutor.cs +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/EfCoreUAuthAtomicExecutor.cs @@ -28,28 +28,33 @@ public Task ExecuteAsync(Func operation, CancellationTo public async Task ExecuteAsync(Func> operation, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(operation); + ct.ThrowIfCancellationRequested(); - // Participate in an already active transaction. if (_db.Database.CurrentTransaction is not null) - { return await operation(ct); - } - await using var transaction = await _db.Database.BeginTransactionAsync(ct); + var strategy = _db.Database.CreateExecutionStrategy(); - try + return await strategy.ExecuteAsync(async () => { - var result = await operation(ct); + await using var transaction = + await _db.Database.BeginTransactionAsync(ct); - await transaction.CommitAsync(ct); + try + { + var result = await operation(ct); - return result; - } - catch - { - await transaction.RollbackAsync(CancellationToken.None); + await _db.SaveChangesAsync(ct); - throw; - } + await transaction.CommitAsync(ct); + + return result; + } + catch + { + await transaction.RollbackAsync(CancellationToken.None); + throw; + } + }); } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs index 40a52a4a..9ac9e960 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs @@ -23,71 +23,75 @@ public EfCoreSessionStore(TDbContext db, TenantExecutionContext tenant) private DbSet DbSetChain => _db.Set(); private DbSet DbSetRoot => _db.Set(); - public async Task ExecuteAsync(Func action, CancellationToken ct = default) + public Task ExecuteAsync(Func action, CancellationToken ct = default) { - var strategy = _db.Database.CreateExecutionStrategy(); - - await strategy.ExecuteAsync(async () => - { - await using var tx = await _db.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); + ArgumentNullException.ThrowIfNull(action); - try - { - _inExecution = true; - await action(ct); - await _db.SaveChangesAsync(ct); - await tx.CommitAsync(ct); - } - catch (DbUpdateConcurrencyException) + return ExecuteAsync( + async innerCt => { - await tx.RollbackAsync(ct); - throw new UAuthConcurrencyException("concurrency_conflict"); - } - catch - { - await tx.RollbackAsync(ct); - throw; - } - finally - { - _inExecution = false; - } - }); + await action(innerCt); + return null; + }, + ct); } public async Task ExecuteAsync(Func> action, CancellationToken ct = default) { + ArgumentNullException.ThrowIfNull(action); + ct.ThrowIfCancellationRequested(); + + // Participate in the existing transaction. + if (_db.Database.CurrentTransaction is not null) + { + return await ExecuteCoreAsync(action, ct); + } + var strategy = _db.Database.CreateExecutionStrategy(); return await strategy.ExecuteAsync(async () => { - await using var tx = await _db.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); + await using var transaction = await _db.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); try { - _inExecution = true; - var result = await action(ct); - await _db.SaveChangesAsync(ct); - await tx.CommitAsync(ct); + var result = await ExecuteCoreAsync(action, ct); + await transaction.CommitAsync(ct); return result; } - catch (DbUpdateConcurrencyException) - { - await tx.RollbackAsync(ct); - throw new UAuthConcurrencyException("concurrency_conflict"); - } catch { - await tx.RollbackAsync(ct); + await transaction.RollbackAsync(CancellationToken.None); throw; } - finally - { - _inExecution = false; - } }); } + private async Task ExecuteCoreAsync(Func> action, CancellationToken ct) + { + if (_inExecution) + throw new InvalidOperationException("A session store execution is already active."); + + _inExecution = true; + + try + { + var result = await action(ct); + + await _db.SaveChangesAsync(ct); + + return result; + } + catch (DbUpdateConcurrencyException) + { + throw new UAuthConcurrencyException("concurrency_conflict"); + } + finally + { + _inExecution = false; + } + } + public async Task GetSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs index c0551c78..23c7c56b 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs @@ -454,6 +454,12 @@ public async Task ChangeSecretAsync_Admin_WhenValid_DoesNotRequireCurrentSecret_ Times.Once); f.CredentialSessionPolicy.Verify(x => x.Resolve(CredentialChangeReason.AdminChange), Times.Once); + + f.SessionStore.Verify( + x => x.ExecuteAsync( + It.IsAny>(), + It.IsAny()), + Times.Once); } // --------------------------------------------------------- @@ -982,6 +988,9 @@ private sealed class Fixture public Mock CredentialSessionPolicy { get; } = new(MockBehavior.Strict); + public Mock AtomicExecutor { get; } + = new(MockBehavior.Strict); + public Mock Clock { get; } = new(MockBehavior.Strict); @@ -1058,6 +1067,13 @@ public Fixture() .Setup(x => x.Create(It.IsAny())) .Returns(SessionStore.Object); + SessionStore + .Setup(x => x.ExecuteAsync( + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>( + (action, ct) => action(ct)); + CredentialSessionPolicy .Setup(x => x.Resolve(CredentialChangeReason.SelfChange)) .Returns(CredentialSessionBehavior.PreserveCurrent); @@ -1070,6 +1086,20 @@ public Fixture() .Setup(x => x.Resolve(CredentialChangeReason.Recovery)) .Returns(CredentialSessionBehavior.RevokeAll); + AtomicExecutor + .Setup(x => x.ExecuteAsync( + It.IsAny>>(), + It.IsAny())) + .Returns>, CancellationToken>( + (action, ct) => action(ct)); + + AtomicExecutor + .Setup(x => x.ExecuteAsync( + It.IsAny>>(), + It.IsAny())) + .Returns>, CancellationToken>( + (action, ct) => action(ct)); + var options = Options.Create( TestServerOptions.Default()); @@ -1085,8 +1115,9 @@ public Fixture() SessionStoreFactory.Object, ResetNotifier.Object, CredentialSessionPolicy.Object, + AtomicExecutor.Object, options, Clock.Object); } } -} \ No newline at end of file +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreCredentialAtomicityTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreCredentialAtomicityTests.cs new file mode 100644 index 00000000..b06267a9 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreCredentialAtomicityTests.cs @@ -0,0 +1,620 @@ +using CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Security; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class EfCoreCredentialAtomicityTests : EfCoreTestBase +{ + private static UAuthDbContext CreateDb(SqliteConnection connection) + { + return CreateDbContext( + connection, + options => new UAuthDbContext(options)); + } + + [Fact] + public async Task ChangeSecret_WhenSessionRevocationFails_ShouldRollbackCredential() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var user = UserKey.FromGuid(Guid.NewGuid()); + var now = DateTimeOffset.UtcNow; + var hasher = new TestPasswordHasher(); + + var credential = PasswordCredential.Create( + Guid.NewGuid(), + tenant, + user, + hasher.Hash("old-password"), + CredentialSecurityState.Active(), + new CredentialMetadata(), + now); + + // Arrange: Persist the original credential. + await using (var db = CreateDb(connection)) + { + var store = new EfCorePasswordCredentialStore( + db, + new TenantExecutionContext(tenant)); + + await store.AddAsync(credential); + } + + // Act: Change password, then simulate a session failure. + await using (var db = CreateDb(connection)) + { + var store = new EfCorePasswordCredentialStore( + db, + new TenantExecutionContext(tenant)); + + var existing = await store.GetAsync( + new CredentialKey(tenant, credential.Id)); + + Assert.NotNull(existing); + + await Assert.ThrowsAsync( + async () => + { + await using var transaction = + await db.Database.BeginTransactionAsync(); + + var updated = existing.ChangeSecret( + hasher.Hash("new-password"), + now.AddMinutes(1)); + + await store.SaveAsync( + updated, + existing.Version); + + // Simulate a failure during session revocation. + throw new InvalidOperationException( + "Simulated session revocation failure"); + }); + } + + // Assert: Read from a fresh DbContext. + await using (var db = CreateDb(connection)) + { + var store = new EfCorePasswordCredentialStore( + db, + new TenantExecutionContext(tenant)); + + var persisted = await store.GetAsync( + new CredentialKey(tenant, credential.Id)); + + Assert.NotNull(persisted); + + Assert.True( + hasher.Verify( + persisted.SecretHash, + "old-password")); + + Assert.False( + hasher.Verify( + persisted.SecretHash, + "new-password")); + + Assert.Equal(0, persisted.Version); + } + } + + [Fact] + public async Task AtomicExecutor_WhenOperationSucceeds_ShouldCommitCredential() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var user = UserKey.FromGuid(Guid.NewGuid()); + var hasher = new TestPasswordHasher(); + var now = DateTimeOffset.UtcNow; + + var credential = PasswordCredential.Create( + Guid.NewGuid(), + tenant, + user, + hasher.Hash("old-password"), + CredentialSecurityState.Active(), + new CredentialMetadata(), + now); + + await using (var db = CreateDb(connection)) + { + var store = new EfCorePasswordCredentialStore( + db, + new TenantExecutionContext(tenant)); + + await store.AddAsync(credential); + } + + await using (var db = CreateDb(connection)) + { + var services = new ServiceCollection(); + + services.AddSingleton(db); + CodeBeam.UltimateAuth.EntityFrameworkCore.Extensions.ServiceCollectionExtensions.AddUltimateAuthEntityFrameworkCore(services); + + await using var provider = services.BuildServiceProvider(); + await using var scope = provider.CreateAsyncScope(); + + var executor = scope.ServiceProvider + .GetRequiredService(); + + var store = new EfCorePasswordCredentialStore( + db, + new TenantExecutionContext(tenant)); + + await executor.ExecuteAsync(async ct => + { + var existing = await store.GetAsync( + new CredentialKey(tenant, credential.Id), + ct); + + Assert.NotNull(existing); + + var updated = existing.ChangeSecret( + hasher.Hash("new-password"), + now.AddMinutes(1)); + + await store.SaveAsync( + updated, + existing.Version, + ct); + + Assert.NotNull(db.Database.CurrentTransaction); + }); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCorePasswordCredentialStore( + db, + new TenantExecutionContext(tenant)); + + var persisted = await store.GetAsync( + new CredentialKey(tenant, credential.Id)); + + Assert.NotNull(persisted); + Assert.True( + hasher.Verify(persisted.SecretHash, "new-password")); + Assert.Equal(1, persisted.Version); + } + } + + [Fact] + public async Task AtomicExecutor_WhenOperationFails_ShouldRollbackCredential() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var user = UserKey.FromGuid(Guid.NewGuid()); + var hasher = new TestPasswordHasher(); + var now = DateTimeOffset.UtcNow; + + var credential = PasswordCredential.Create( + Guid.NewGuid(), + tenant, + user, + hasher.Hash("old-password"), + CredentialSecurityState.Active(), + new CredentialMetadata(), + now); + + await using (var db = CreateDb(connection)) + { + var store = new EfCorePasswordCredentialStore( + db, + new TenantExecutionContext(tenant)); + + await store.AddAsync(credential); + } + + await using (var db = CreateDb(connection)) + { + var services = new ServiceCollection(); + + services.AddSingleton(db); + CodeBeam.UltimateAuth.EntityFrameworkCore.Extensions.ServiceCollectionExtensions.AddUltimateAuthEntityFrameworkCore(services); + + await using var provider = services.BuildServiceProvider(); + await using var scope = provider.CreateAsyncScope(); + + var executor = scope.ServiceProvider.GetRequiredService(); + + var store = new EfCorePasswordCredentialStore( + db, + new TenantExecutionContext(tenant)); + + await Assert.ThrowsAsync( + () => executor.ExecuteAsync(async ct => + { + var existing = await store.GetAsync( + new CredentialKey(tenant, credential.Id), + ct); + + Assert.NotNull(existing); + + var updated = existing.ChangeSecret( + hasher.Hash("new-password"), + now.AddMinutes(1)); + + await store.SaveAsync( + updated, + existing.Version, + ct); + + Assert.NotNull(db.Database.CurrentTransaction); + + throw new InvalidOperationException( + "Simulated session revocation failure"); + })); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCorePasswordCredentialStore( + db, + new TenantExecutionContext(tenant)); + + var persisted = await store.GetAsync( + new CredentialKey(tenant, credential.Id)); + + Assert.NotNull(persisted); + + Assert.True( + hasher.Verify(persisted.SecretHash, "old-password")); + + Assert.False( + hasher.Verify(persisted.SecretHash, "new-password")); + + Assert.Equal(0, persisted.Version); + } + } + + [Fact] + public async Task AtomicExecutor_WhenSessionOperationFails_ShouldRollbackBothStores() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var user = UserKey.FromGuid(Guid.NewGuid()); + var hasher = new TestPasswordHasher(); + var now = DateTimeOffset.UtcNow; + + var credential = PasswordCredential.Create( + Guid.NewGuid(), + tenant, + user, + hasher.Hash("old-password"), + CredentialSecurityState.Active(), + new CredentialMetadata(), + now); + + // Arrange: Persist credential and session. + AuthSessionId.TryCreate( + "session-aaaaaaaaaaaaaaaaaaaaaaaaaaaa", + out var sessionId); + + var root = UAuthSessionRoot.Create(tenant, user, now); + + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + root.RootId, + tenant, + user, + now, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + 0); + + var session = UAuthSession.Create( + sessionId, + tenant, + user, + chain.ChainId, + now, + now.AddHours(1), + 0, + TestDevice.Default(), + ClaimsSnapshot.Empty, + SessionMetadata.Empty); + + await using (var db = CreateDb(connection)) + { + var credentialStore = + new EfCorePasswordCredentialStore( + db, + new TenantExecutionContext(tenant)); + + var sessionStore = + new EfCoreSessionStore( + db, + new TenantExecutionContext(tenant)); + + await credentialStore.AddAsync(credential); + + await sessionStore.ExecuteAsync(async ct => + { + await sessionStore.CreateRootAsync(root, ct); + await sessionStore.CreateChainAsync(chain, ct); + await sessionStore.CreateSessionAsync(session, ct); + }); + } + + // Act: Change password and revoke session within one atomic operation. + await using (var db = CreateDb(connection)) + { + var services = new ServiceCollection(); + + services.AddSingleton(db); + + CodeBeam.UltimateAuth.EntityFrameworkCore.Extensions + .ServiceCollectionExtensions + .AddUltimateAuthEntityFrameworkCore(services); + + await using var provider = services.BuildServiceProvider(); + await using var scope = provider.CreateAsyncScope(); + + var executor = scope.ServiceProvider + .GetRequiredService(); + + var credentialStore = + new EfCorePasswordCredentialStore( + db, + new TenantExecutionContext(tenant)); + + var sessionStore = + new EfCoreSessionStore( + db, + new TenantExecutionContext(tenant)); + + await Assert.ThrowsAsync( + () => executor.ExecuteAsync(async ct => + { + var existing = await credentialStore.GetAsync( + new CredentialKey(tenant, credential.Id), + ct); + + Assert.NotNull(existing); + + var updated = existing.ChangeSecret( + hasher.Hash("new-password"), + now.AddMinutes(1)); + + await credentialStore.SaveAsync( + updated, + existing.Version, + ct); + + await sessionStore.ExecuteAsync(async sessionCt => + { + await sessionStore.RevokeAllChainsAsync( + user, + now.AddMinutes(1), + sessionCt); + }, ct); + + // Session operation succeeded, but the outer operation fails. + throw new InvalidOperationException( + "Simulated failure before outer commit"); + })); + } + + // Assert: Both credential and session changes must be rolled back. + await using (var db = CreateDb(connection)) + { + var credentialStore = + new EfCorePasswordCredentialStore( + db, + new TenantExecutionContext(tenant)); + + var sessionStore = + new EfCoreSessionStore( + db, + new TenantExecutionContext(tenant)); + + var persisted = await credentialStore.GetAsync( + new CredentialKey(tenant, credential.Id)); + + Assert.NotNull(persisted); + + Assert.True( + hasher.Verify( + persisted.SecretHash, + "old-password")); + + Assert.Equal(0, persisted.Version); + + var persistedSession = await sessionStore.GetSessionAsync(sessionId); + + Assert.NotNull(persistedSession); + Assert.False(persistedSession.IsRevoked); + } + } + + [Fact] + public async Task AtomicReset_WhenSessionRevocationFails_ShouldRollbackSecurityStateAndCredential() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var user = UserKey.FromGuid(Guid.NewGuid()); + var now = DateTimeOffset.UtcNow; + var hasher = new TestPasswordHasher(); + + const string resetTokenHash = "test-reset-token-hash"; + + var credential = PasswordCredential.Create( + Guid.NewGuid(), + tenant, + user, + hasher.Hash("old-password"), + CredentialSecurityState.Active(), + new CredentialMetadata(), + now); + + var securityState = AuthenticationSecurityState + .CreateAccount(tenant, user); + + // Arrange: Persist an active reset state and original password. + await using (var db = CreateDb(connection)) + { + var credentialStore = + new EfCorePasswordCredentialStore( + db, + new TenantExecutionContext(tenant)); + + var securityStore = + new EfCoreAuthenticationSecurityStateStore( + db, + new TenantExecutionContext(tenant)); + + await credentialStore.AddAsync(credential); + + await securityStore.AddAsync(securityState); + + var activeReset = securityState.BeginReset( + resetTokenHash, + now, + TimeSpan.FromMinutes(10)); + + await securityStore.UpdateAsync( + activeReset, + securityState.SecurityVersion); + } + + // Act: Consume reset token and change password, + // then fail before committing the transaction. + await using (var db = CreateDb(connection)) + { + var services = new ServiceCollection(); + + services.AddSingleton(db); + + CodeBeam.UltimateAuth.EntityFrameworkCore.Extensions + .ServiceCollectionExtensions + .AddUltimateAuthEntityFrameworkCore(services); + + await using var provider = services.BuildServiceProvider(); + await using var scope = provider.CreateAsyncScope(); + + var executor = scope.ServiceProvider + .GetRequiredService(); + + var credentialStore = + new EfCorePasswordCredentialStore( + db, + new TenantExecutionContext(tenant)); + + var securityStore = + new EfCoreAuthenticationSecurityStateStore( + db, + new TenantExecutionContext(tenant)); + + var sessionStore = + new EfCoreSessionStore( + db, + new TenantExecutionContext(tenant)); + + await Assert.ThrowsAsync( + () => executor.ExecuteAsync(async ct => + { + var state = await securityStore.GetAsync( + user, + AuthenticationSecurityScope.Account, + null, + ct); + + Assert.NotNull(state); + Assert.True(state.HasActiveReset(now)); + + var consumed = state.ConsumeReset(now); + + await securityStore.UpdateAsync( + consumed, + state.SecurityVersion, + ct); + + var existing = await credentialStore.GetAsync( + new CredentialKey(tenant, credential.Id), + ct); + + Assert.NotNull(existing); + + var updated = existing.ChangeSecret( + hasher.Hash("new-password"), + now); + + await credentialStore.SaveAsync( + updated, + existing.Version, + ct); + + await sessionStore.ExecuteAsync(async sessionCt => + { + await sessionStore.RevokeAllChainsAsync( + user, + now, + sessionCt); + }, ct); + + throw new InvalidOperationException( + "Simulated failure before reset commit"); + })); + } + + // Assert: Both security state and credential must be unchanged. + await using (var db = CreateDb(connection)) + { + var credentialStore = + new EfCorePasswordCredentialStore( + db, + new TenantExecutionContext(tenant)); + + var securityStore = + new EfCoreAuthenticationSecurityStateStore( + db, + new TenantExecutionContext(tenant)); + + var persistedCredential = await credentialStore.GetAsync( + new CredentialKey(tenant, credential.Id)); + + Assert.NotNull(persistedCredential); + + Assert.True( + hasher.Verify( + persistedCredential.SecretHash, + "old-password")); + + Assert.False( + hasher.Verify( + persistedCredential.SecretHash, + "new-password")); + + Assert.Equal(0, persistedCredential.Version); + + var persistedState = await securityStore.GetAsync( + user, + AuthenticationSecurityScope.Account, + null); + + Assert.NotNull(persistedState); + + Assert.True(persistedState.HasActiveReset(now)); + Assert.Null(persistedState.ResetConsumedAt); + Assert.Equal(resetTokenHash, persistedState.ResetTokenHash); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreSessionStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreSessionStoreTests.cs index d2c319d8..dd6d81da 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreSessionStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreSessionStoreTests.cs @@ -901,4 +901,35 @@ await store.SaveRootAsync( result.IsRevoked.Should().BeTrue(); } } + + [Fact] + public async Task ExecuteAsync_Should_Join_Existing_Transaction() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var store = new EfCoreSessionStore( + db, + new TenantExecutionContext(TenantKeys.Single)); + + await using var transaction = + await db.Database.BeginTransactionAsync(); + + var originalTransaction = db.Database.CurrentTransaction; + + await store.ExecuteAsync(async ct => + { + Assert.Same( + originalTransaction, + db.Database.CurrentTransaction); + + await Task.CompletedTask; + }); + + Assert.Same( + originalTransaction, + db.Database.CurrentTransaction); + + await transaction.RollbackAsync(); + } }