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/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.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 eff28d65..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)
@@ -563,7 +573,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 +630,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 +654,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)
@@ -676,16 +686,51 @@ 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);
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;
+
+ 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);
});
@@ -701,7 +746,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/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 505f0083..b8896209 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,144 @@ 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)
+ {
+ 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 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 = 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
+ {
+ id = identifier.Id,
+ newValue,
+ mode = 0,
+ proof = operation == "verify"
+ ? TestUserIdentifierVerifier.ValidProof
+ : null
+ });
+ 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 +357,26 @@ 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])));
+ }
+
+ 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(