From 00205b42e9ad656ee6f3e7af8b6e726c662f67d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Wed, 7 Oct 2026 23:02:09 +0300 Subject: [PATCH 1/2] Fix UserIdentifier Mutation Can be Done Across Non Targeted Users --- .../UAuthExceptionHandlingExtensions.cs | 3 + .../Services/UserApplicationService.cs | 10 +- .../UserIdentifierTests.cs | 135 +++++++++++++++++- 3 files changed, 141 insertions(+), 7 deletions(-) diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs index 6dd8d03a..f2c6a3d7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs @@ -54,6 +54,9 @@ private static int MapStatusCode(UAuthRuntimeException ex) => UAuthForbiddenException => StatusCodes.Status403Forbidden, UAuthNotFoundException => StatusCodes.Status404NotFound, UAuthChallengeRequiredException => StatusCodes.Status401Unauthorized, + UAuthIdentifierNotFoundException => StatusCodes.Status404NotFound, + UAuthIdentifierConflictException => StatusCodes.Status409Conflict, + UAuthIdentifierValidationException => StatusCodes.Status400BadRequest, _ => StatusCodes.Status400BadRequest }; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index eff28d65..a8cff215 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -563,7 +563,7 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt); - if (identifier is null || identifier.IsDeleted) + if (identifier is null || identifier.IsDeleted || identifier.UserKey != context.GetTargetUserKey()) throw new UAuthIdentifierNotFoundException("identifier_not_found"); if (identifier.Type == UserIdentifierType.Username && !_options.Identifiers.Behavior.AllowUsernameChange) @@ -620,7 +620,7 @@ public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimar var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt); - if (identifier is null || identifier.IsDeleted) + if (identifier is null || identifier.IsDeleted || identifier.UserKey != context.GetTargetUserKey()) throw new UAuthIdentifierNotFoundException("identifier_not_found"); if (identifier.IsPrimary) @@ -644,7 +644,7 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt); - if (identifier is null) + if (identifier is null || identifier.IsDeleted || identifier.UserKey != context.GetTargetUserKey()) throw new UAuthIdentifierNotFoundException("identifier_not_found"); if (!identifier.IsPrimary) @@ -682,7 +682,7 @@ public async Task VerifyUserIdentifierAsync(AccessContext context, VerifyUserIde var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt); - if (identifier is null) + if (identifier is null || identifier.IsDeleted || identifier.UserKey != context.GetTargetUserKey()) throw new UAuthIdentifierNotFoundException("identifier_not_found"); var expectedVersion = identifier.Version; @@ -701,7 +701,7 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt); - if (identifier is null) + if (identifier is null || identifier.IsDeleted || identifier.UserKey != context.GetTargetUserKey()) throw new UAuthIdentifierNotFoundException("identifier_not_found"); var identifiers = await identifierStore.GetByUserAsync(identifier.UserKey, innerCt); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs index 505f0083..98e11e23 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs @@ -1,7 +1,11 @@ -using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +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; using FluentAssertions; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; using System.Net; using System.Net.Http.Json; @@ -184,6 +188,123 @@ await response.Content result.Errors.Should().NotBeEmpty(); } + [Theory] + [InlineData("update", false)] + [InlineData("set-primary", false)] + [InlineData("unset-primary", false)] + [InlineData("verify", false)] + [InlineData("delete", false)] + [InlineData("update", true)] + [InlineData("set-primary", true)] + [InlineData("unset-primary", true)] + [InlineData("verify", true)] + [InlineData("delete", true)] + public async Task IdentifierMutation_WhenIdBelongsToAnotherUser_ShouldReturnNotFoundAndPreserveRecords( + string operation, bool admin) + { + var actor = await _factory.CreateLoginUserAsync(); + var victim = await _factory.CreateLoginUserAsync(); + if (admin) + await _factory.GrantPermissionsAsync(actor.UserKey, new[] { "*" }); + + using var scope = _factory.Services.CreateScope(); + var services = scope.ServiceProvider; + var store = services.GetRequiredService() + .Create(TenantKeys.Single); + var normalizer = services.GetRequiredService(); + var value = $"ownership-{Guid.NewGuid():N}@example.com"; + var identifier = UserIdentifier.Create( + Guid.NewGuid(), TenantKeys.Single, victim.UserKey, + UserIdentifierType.Email, value, + normalizer.Normalize(UserIdentifierType.Email, value).Normalized, + _factory.Clock.UtcNow, + isPrimary: operation == "unset-primary", + verifiedAt: operation == "verify" ? null : _factory.Clock.UtcNow); + await store.AddAsync(identifier); + + var before = (await store.GetByUserAsync(victim.UserKey)) + .Select(x => x.Snapshot()).ToArray(); + using var client = CreateClient(); + await AuthenticateIdentifierClientAsync(client, actor.Identifier, actor.Secret); + + // Prove authentication works before exercising the ownership boundary. + using var authenticated = await client.PostAsJsonAsync( + "/auth/me/identifiers/get", new { }); + authenticated.StatusCode.Should().Be(HttpStatusCode.OK); + + var path = admin + ? $"/auth/admin/users/{actor.UserKey.Value}/identifiers/{operation}" + : $"/auth/me/identifiers/{operation}"; + + // Admin policies forbid modifying the actor themselves. + // Use a separate route target whose identifiers are NOT the victim's. + if (admin) + { + var target = await _factory.CreateLoginUserAsync(); + path = $"/auth/admin/users/{target.UserKey.Value}/identifiers/{operation}"; + } + + using var response = await client.PostAsJsonAsync(path, new + { + id = identifier.Id, + newValue = $"changed-{Guid.NewGuid():N}@example.com", + mode = 0 // DeleteMode.Soft + }); + + var after = await store.GetByUserAsync(victim.UserKey); + using var assertions = new FluentAssertions.Execution.AssertionScope(); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + after.Should().BeEquivalentTo(before, + "rejected operations must not mutate the victim or their primary identifiers"); + } + + [Theory] + [InlineData("update")] + [InlineData("set-primary")] + [InlineData("unset-primary")] + [InlineData("verify")] + [InlineData("delete")] + public async Task IdentifierMutation_WhenIdBelongsToSelf_ShouldSucceed(string operation) + { + var owner = await _factory.CreateLoginUserAsync(); + using var scope = _factory.Services.CreateScope(); + var services = scope.ServiceProvider; + var store = services.GetRequiredService() + .Create(TenantKeys.Single); + var normalizer = services.GetRequiredService(); + var value = $"owned-{Guid.NewGuid():N}@example.com"; + var identifier = UserIdentifier.Create( + Guid.NewGuid(), TenantKeys.Single, owner.UserKey, + UserIdentifierType.Email, value, + normalizer.Normalize(UserIdentifierType.Email, value).Normalized, + _factory.Clock.UtcNow, + isPrimary: operation == "unset-primary", + verifiedAt: operation == "verify" ? null : _factory.Clock.UtcNow); + await store.AddAsync(identifier); + using var client = CreateClient(); + await AuthenticateIdentifierClientAsync(client, owner.Identifier, owner.Secret); + var newValue = $"updated-{Guid.NewGuid():N}@example.com"; + using var response = await client.PostAsJsonAsync( + $"/auth/me/identifiers/{operation}", new + { + id = identifier.Id, + newValue, + mode = 0 + }); + response.IsSuccessStatusCode.Should().BeTrue( + $"own identifier operation should succeed: {await response.Content.ReadAsStringAsync()}"); + var saved = await store.GetByIdAsync(identifier.Id); + saved.Should().NotBeNull(); + switch (operation) + { + case "update": saved!.Value.Should().Be(newValue); break; + case "set-primary": saved!.IsPrimary.Should().BeTrue(); break; + case "unset-primary": saved!.IsPrimary.Should().BeFalse(); break; + case "verify": saved!.IsVerified.Should().BeTrue(); break; + case "delete": saved!.IsDeleted.Should().BeTrue(); break; + } + } + // TODO: // Expand UserIdentifier integration coverage: // @@ -215,4 +336,14 @@ private HttpClient CreateClient() return client; } -} \ No newline at end of file + + private static async Task AuthenticateIdentifierClientAsync(HttpClient client, string identifier, string secret) + { + using var login = await client.PostAsJsonAsync("/auth/login", new { identifier, secret }); + login.StatusCode.Should().Be(HttpStatusCode.Found); + login.Headers.TryGetValues("Set-Cookie", out var cookies).Should().BeTrue(); + + // Send only cookie name/value pairs, not Set-Cookie attributes. + client.DefaultRequestHeaders.Add("Cookie", string.Join("; ", cookies!.Select(x => x.Split(';')[0]))); + } +} From 232c10b9aab3dcc6714e22568377c155179fdc27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Thu, 8 Oct 2026 00:13:17 +0300 Subject: [PATCH 2/2] Fix Identifier Verifier Abuse --- .../Dialogs/IdentifierDialog.razor.cs | 17 ++++-- ...DevelopmentBypassUserIdentifierVerifier.cs | 23 ++++++++ .../Program.cs | 1 + .../Requests/VerifyUserIdentifierRequest.cs | 6 +++ .../Extensions/ServiceCollectonExtensions.cs | 2 + .../NotConfiguredUserIdentifierVerifier.cs | 18 +++++++ .../Services/UserApplicationService.cs | 53 +++++++++++++++++-- .../Abstractions/IUserIdentifierVerifier.cs | 24 +++++++++ .../UserIdentifierTests.cs | 51 ++++++++++++++---- .../UserLifecycleTests.cs | 53 +++++-------------- .../Users/UserApplicationServiceTests.cs | 26 ++++++++- 11 files changed, 216 insertions(+), 58 deletions(-) create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DevelopmentBypassUserIdentifierVerifier.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/NotConfiguredUserIdentifierVerifier.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserIdentifierVerifier.cs diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor.cs index 1a2f0a76..77d8e254 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor.cs @@ -190,10 +190,12 @@ private async Task VerifyAsync(Guid id) title: "Demo Verification", markupMessage: (MarkupString) """ - This is a demo action.

- In a real app, you should verify identifiers via Email, SMS, or an Authenticator flow. - This will only mark the identifier as verified in UltimateAuth. - """, + This Development sample bypasses ownership verification.

+ No email or SMS proof is checked. The identifier will be marked + as verified for demonstration purposes only.

+ Production applications must implement a real + IUserIdentifierVerifier. + """, yesText: "Verify", noText: "Cancel", options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); @@ -203,7 +205,12 @@ This will only mark the identifier as verified in UltimateAuth. return; } - VerifyUserIdentifierRequest request = new() { Id = id }; + VerifyUserIdentifierRequest request = new() + { + Id = id, + Proof = UserKey is null ? "demo-verification-bypass" : null + }; + UAuthResult result; if (UserKey is null) diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DevelopmentBypassUserIdentifierVerifier.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DevelopmentBypassUserIdentifierVerifier.cs new file mode 100644 index 00000000..0a4e4f5b --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DevelopmentBypassUserIdentifierVerifier.cs @@ -0,0 +1,23 @@ +using CodeBeam.UltimateAuth.Users; +using Microsoft.Extensions.Hosting; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure; + +// TODO: Replace this demo bypass with a real verification challenge flow. +// Never register this implementation in production. +internal sealed class DevelopmentBypassUserIdentifierVerifier(IHostEnvironment environment) : IUserIdentifierVerifier +{ + public const string DemoProof = "demo-verification-bypass"; + + public Task VerifyAsync(UserIdentifierVerificationContext context, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!environment.IsDevelopment()) + { + throw new InvalidOperationException("Development verification bypass cannot run outside Development."); + } + + return Task.FromResult(string.Equals(context.Proof, DemoProof, StringComparison.Ordinal)); + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index fb79b4a0..d26cbda0 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -67,6 +67,7 @@ if (builder.Environment.IsDevelopment()) { builder.Services.AddScoped(); + builder.Services.AddScoped(); } builder.Services.Configure(options => diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs index 205c5460..5e5a6a86 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs @@ -3,4 +3,10 @@ public sealed record VerifyUserIdentifierRequest { public Guid Id { get; init; } + + /// + /// Required for self verification. + /// Not required for authorized admin verification. + /// + public string? Proof { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs index ea1a0174..35c99919 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs @@ -23,6 +23,8 @@ public static IServiceCollection AddUltimateAuthUsersReference(this IServiceColl services.TryAddScoped(); services.AddScoped(); + services.TryAddScoped(); + return services; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/NotConfiguredUserIdentifierVerifier.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/NotConfiguredUserIdentifierVerifier.cs new file mode 100644 index 00000000..d4c3035a --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/NotConfiguredUserIdentifierVerifier.cs @@ -0,0 +1,18 @@ +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class NotConfiguredUserIdentifierVerifier : IUserIdentifierVerifier +{ + internal static InvalidOperationException CreateException() => new( + "User identifier verification is not configured. " + + "Implement IUserIdentifierVerifier and register it with " + + "services.AddScoped(). " + + "The implementation must validate ownership proof for the " + + "specified tenant, user, identifier and current value."); + + public Task VerifyAsync(UserIdentifierVerificationContext context, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + throw CreateException(); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index a8cff215..674ac3b7 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -1,14 +1,15 @@ using CodeBeam.UltimateAuth.Core.Abstractions; 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.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; -using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Server.Services; using CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Users.Contracts; using Microsoft.Extensions.Options; -using CodeBeam.UltimateAuth.Server.Services; namespace CodeBeam.UltimateAuth.Users.Reference; @@ -25,6 +26,7 @@ internal sealed class UserApplicationService : IUserApplicationService private readonly IIdentifierNormalizer _identifierNormalizer; private readonly IUserIdentifierAvailabilityService _identifierAvailabilityService; private readonly ISessionStoreFactory _sessionStoreFactory; + private readonly IUserIdentifierVerifier _identifierVerifier; private readonly UAuthServerOptions _options; private readonly IClock _clock; @@ -40,6 +42,7 @@ public UserApplicationService( IIdentifierNormalizer identifierNormalizer, IUserIdentifierAvailabilityService identifierAvailabilityService, ISessionStoreFactory sessionStoreFactory, + IUserIdentifierVerifier ıdentifierVerifier, IOptions options, IClock clock) { @@ -54,6 +57,7 @@ public UserApplicationService( _identifierNormalizer = identifierNormalizer; _identifierAvailabilityService = identifierAvailabilityService; _sessionStoreFactory = sessionStoreFactory; + _identifierVerifier = ıdentifierVerifier; _options = options.Value; _clock = clock; } @@ -74,6 +78,12 @@ public async Task CreateUserAsync(AccessContext context, Creat async atomicCt => { var now = _clock.UtcNow; + + var allowVerifiedContacts = context.IsSystemActor || + ( + context.IsAuthenticated && string.Equals(context.Action, UAuthActions.Users.CreateAdmin, StringComparison.Ordinal) + ); + var userKey = UserKey.New(); var lifecycleStore = _lifecycleStoreFactory.Create(context.ResourceTenant); @@ -125,7 +135,7 @@ await identifierStore.AddAsync( _identifierNormalizer.Normalize(UserIdentifierType.Email, request.Email).Normalized, now, true, - request.EmailVerified ? now : null), atomicCt); + allowVerifiedContacts && request.EmailVerified ? now : null), atomicCt); } if (!string.IsNullOrWhiteSpace(request.Phone)) @@ -140,7 +150,7 @@ await identifierStore.AddAsync( _identifierNormalizer.Normalize(UserIdentifierType.Phone, request.Phone).Normalized, now, true, - request.PhoneVerified ? now : null), atomicCt); + allowVerifiedContacts && request.PhoneVerified ? now : null), atomicCt); } foreach (var integration in _integrations) @@ -676,8 +686,12 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr public async Task VerifyUserIdentifierAsync(AccessContext context, VerifyUserIdentifierRequest request, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var command = new AccessCommand(async innerCt => { + innerCt.ThrowIfCancellationRequested(); + EnsureOverrideAllowed(context); var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); @@ -686,6 +700,37 @@ public async Task VerifyUserIdentifierAsync(AccessContext context, VerifyUserIde throw new UAuthIdentifierNotFoundException("identifier_not_found"); var expectedVersion = identifier.Version; + + if (context.IsSelfAction) + { + if (_identifierVerifier is NotConfiguredUserIdentifierVerifier) + throw NotConfiguredUserIdentifierVerifier.CreateException(); + + if (string.IsNullOrWhiteSpace(request.Proof)) + throw new UAuthIdentifierValidationException("identifier_verification_proof_required"); + + var verified = await _identifierVerifier.VerifyAsync( + new UserIdentifierVerificationContext( + Tenant: context.ResourceTenant, + UserKey: identifier.UserKey, + IdentifierId: identifier.Id, + Type: identifier.Type, + Value: identifier.Value, + Proof: request.Proof), + innerCt); + + if (!verified) + throw new UAuthIdentifierValidationException("identifier_verification_failed"); + } + + // TODO: Provide a built-in identifier verification challenge flow: + // issuance, hashed storage, expiry, attempt limits and notification. + // Bind each challenge to tenant, user, identifier and current value. + // + // TODO: Coordinate single-use proof consumption with persistence. + // A concurrent identifier change must not be marked verified + // using proof issued for the previous value. + identifier.MarkVerified(_clock.UtcNow); await identifierStore.SaveAsync(identifier, expectedVersion, innerCt); }); diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserIdentifierVerifier.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserIdentifierVerifier.cs new file mode 100644 index 00000000..dc634806 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserIdentifierVerifier.cs @@ -0,0 +1,24 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; + +/// +/// Validates identifier ownership proof on the server. +/// +/// +/// Implementations must bind the proof to the tenant, user, +/// identifier and current value; enforce expiry, attempt limits +/// and single-use consumption. +/// +public interface IUserIdentifierVerifier +{ + Task VerifyAsync(UserIdentifierVerificationContext context, CancellationToken ct = default); +} + +public sealed record UserIdentifierVerificationContext( + TenantKey Tenant, + UserKey UserKey, + Guid IdentifierId, + UserIdentifierType Type, + string Value, + string Proof); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs index 98e11e23..b8896209 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs @@ -266,30 +266,51 @@ public async Task IdentifierMutation_WhenIdBelongsToAnotherUser_ShouldReturnNotF [InlineData("delete")] public async Task IdentifierMutation_WhenIdBelongsToSelf_ShouldSucceed(string operation) { - var owner = await _factory.CreateLoginUserAsync(); - using var scope = _factory.Services.CreateScope(); + using var configuredFactory = operation == "verify" + ? AuthServerFactory.CreateWithServices(services => + services.AddSingleton()) + : null; + + var factory = configuredFactory ?? _factory; + + var owner = await factory.CreateLoginUserAsync(); + using var scope = factory.Services.CreateScope(); var services = scope.ServiceProvider; - var store = services.GetRequiredService() - .Create(TenantKeys.Single); + var store = services.GetRequiredService().Create(TenantKeys.Single); var normalizer = services.GetRequiredService(); var value = $"owned-{Guid.NewGuid():N}@example.com"; var identifier = UserIdentifier.Create( Guid.NewGuid(), TenantKeys.Single, owner.UserKey, UserIdentifierType.Email, value, normalizer.Normalize(UserIdentifierType.Email, value).Normalized, - _factory.Clock.UtcNow, + factory.Clock.UtcNow, isPrimary: operation == "unset-primary", - verifiedAt: operation == "verify" ? null : _factory.Clock.UtcNow); + verifiedAt: operation == "verify" ? null : factory.Clock.UtcNow); await store.AddAsync(identifier); - using var client = CreateClient(); + using var client = factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + client.DefaultRequestHeaders.Add( + "Origin", "https://localhost:6130"); + + client.DefaultRequestHeaders.Add( + "X-UDID", $"identifier-owner-{Guid.NewGuid():N}"); await AuthenticateIdentifierClientAsync(client, owner.Identifier, owner.Secret); var newValue = $"updated-{Guid.NewGuid():N}@example.com"; using var response = await client.PostAsJsonAsync( - $"/auth/me/identifiers/{operation}", new + $"/auth/me/identifiers/{operation}", + new { id = identifier.Id, newValue, - mode = 0 + mode = 0, + proof = operation == "verify" + ? TestUserIdentifierVerifier.ValidProof + : null }); response.IsSuccessStatusCode.Should().BeTrue( $"own identifier operation should succeed: {await response.Content.ReadAsStringAsync()}"); @@ -346,4 +367,16 @@ private static async Task AuthenticateIdentifierClientAsync(HttpClient client, s // Send only cookie name/value pairs, not Set-Cookie attributes. client.DefaultRequestHeaders.Add("Cookie", string.Join("; ", cookies!.Select(x => x.Split(';')[0]))); } + + private sealed class TestUserIdentifierVerifier : IUserIdentifierVerifier + { + public const string ValidProof = "integration-valid-proof"; + + public Task VerifyAsync(UserIdentifierVerificationContext context, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return Task.FromResult(context.Proof == ValidProof); + } + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs index 05b18a09..872c3a57 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs @@ -210,55 +210,30 @@ await store.GetByUserAsync( } [Fact] - public async Task CreateUser_WithVerifiedEmail_ShouldPersistEmailAsVerified() + public async Task CreateUser_WithVerifiedEmail_ShouldNotPersistEmailAsVerified() { _factory.Clock.Reset(); using var client = CreateClient(); - var username = - $"email-user-{Guid.NewGuid():N}"; + var username = $"email-user-{Guid.NewGuid():N}"; - var email = - $"{Guid.NewGuid():N}@example.com"; + var email = $"{Guid.NewGuid():N}@example.com"; - var result = await CreateUserAsync( - client, - username, - email: email, - emailVerified: true); + var result = await CreateUserAsync(client, username, email: email, emailVerified: true); var userKey = GetUserKey(result); - using var scope = - _factory.Services.CreateScope(); - - var factory = - scope.ServiceProvider - .GetRequiredService(); - - var store = - factory.Create(TenantKeys.Single); - - var identifiers = - await store.GetByUserAsync( - userKey); - - var identifier = identifiers - .Single(x => - x.Type == UserIdentifierType.Email); - - identifier.Value.Should() - .Be(email); - - identifier.IsPrimary.Should() - .BeTrue(); - - identifier.IsVerified.Should() - .BeTrue(); - - identifier.VerifiedAt.Should() - .Be(_factory.Clock.UtcNow); + using var scope = _factory.Services.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService(); + var store = factory.Create(TenantKeys.Single); + var identifiers = await store.GetByUserAsync(userKey); + var identifier = identifiers.Single(x => x.Type == UserIdentifierType.Email); + + identifier.Value.Should().Be(email); + identifier.IsPrimary.Should().BeTrue(); + identifier.IsVerified.Should().BeFalse("anonymous registration must ignore client-supplied verification flags"); + identifier.VerifiedAt.Should().BeNull(); } [Fact] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs index 0c2a902a..57cd6d66 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs @@ -840,6 +840,20 @@ public async Task VerifyUserIdentifierAsync_WhenIdentifierExists_MarksVerifiedAn var expectedVersion = identifier.Version; + const string proof = "valid-test-proof"; + + f.IdentifierVerifier + .Setup(x => x.VerifyAsync( + It.Is(c => + c.Tenant == context.ResourceTenant && + c.UserKey == identifier.UserKey && + c.IdentifierId == identifier.Id && + c.Type == identifier.Type && + c.Value == identifier.Value && + c.Proof == proof), + It.IsAny())) + .ReturnsAsync(true); + f.IdentifierStore .Setup(x => x.GetByIdAsync( identifier.Id, @@ -857,7 +871,8 @@ await f.Sut.VerifyUserIdentifierAsync( context, new VerifyUserIdentifierRequest { - Id = identifier.Id + Id = identifier.Id, + Proof = proof }); identifier.IsVerified.Should().BeTrue(); @@ -869,6 +884,11 @@ await f.Sut.VerifyUserIdentifierAsync( expectedVersion, It.IsAny()), Times.Once); + + f.IdentifierVerifier.Verify(x => x.VerifyAsync( + It.IsAny(), + It.IsAny()), + Times.Once); } // ============================================================ @@ -1552,6 +1572,7 @@ private static Fixture CreateFixture(params IUserLifecycleIntegration[] integrat var identifierValidator = new Mock(MockBehavior.Strict); var normalizer = new Mock(MockBehavior.Strict); var identifierAvailability = new Mock(MockBehavior.Strict); + var identifierVerifier = new Mock(MockBehavior.Strict); var sessionFactory = new Mock(MockBehavior.Strict); var sessionStore = new Mock(MockBehavior.Strict); var clock = new Mock(MockBehavior.Strict); @@ -1616,6 +1637,7 @@ private static Fixture CreateFixture(params IUserLifecycleIntegration[] integrat normalizer.Object, identifierAvailability.Object, sessionFactory.Object, + identifierVerifier.Object, options, clock.Object); @@ -1629,6 +1651,7 @@ private static Fixture CreateFixture(params IUserLifecycleIntegration[] integrat validator, identifierValidator, normalizer, + identifierVerifier, sessionStore); } @@ -1795,6 +1818,7 @@ private sealed record Fixture( Mock UserCreateValidator, Mock IdentifierValidator, Mock IdentifierNormalizer, + Mock IdentifierVerifier, Mock SessionStore); private static UserIdentifier CreateIdentifier(