diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor
index 06a515aa..8d4c90d3 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor
@@ -25,8 +25,7 @@
Request Reset
@if (_resetRequested)
{
- Your reset code is: (Copy it before next step)
- @_resetCode
+ Your reset code is on the server console.
Use Reset Code
}
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor.cs
index a819f039..f70034d2 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor.cs
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor.cs
@@ -9,7 +9,6 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Dialogs;
public partial class ResetDialog
{
private bool _resetRequested = false;
- private string? _resetCode;
private string? _identifier;
[CascadingParameter]
@@ -34,7 +33,6 @@ private async Task RequestResetAsync()
return;
}
- _resetCode = result.Value.Token;
_resetRequested = true;
}
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor
index d09fcfa0..26863c14 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor
@@ -1,6 +1,4 @@
-@using CodeBeam.UltimateAuth.Core.Contracts
-@using CodeBeam.UltimateAuth.Users.Contracts
-@inject IUAuthClient UAuthClient
+@inject IUAuthClient UAuthClient
@inject ISnackbar Snackbar
@inject IDialogService DialogService
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor
index 06a515aa..4809f4f7 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor
@@ -1,7 +1,4 @@
-@using CodeBeam.UltimateAuth.Core.Contracts
-@using CodeBeam.UltimateAuth.Credentials.Contracts
-@using CodeBeam.UltimateAuth.Users.Contracts
-@inject IUAuthClient UAuthClient
+@inject IUAuthClient UAuthClient
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject IUAuthStateManager StateManager
@@ -15,8 +12,8 @@
- This is a demonstration of how to implement a credential reset flow.
- In a production application, you should use reset token or code in email, SMS etc. verification steps.
+ In this development sample, reset codes are written to the server console by a custom ICredentialResetNotifier.
+ Production applications should send them through a trusted email or SMS channel.
Reset request always returns ok even with not found users due to security reasons.
@@ -25,9 +22,8 @@
Request Reset
@if (_resetRequested)
{
- Your reset code is: (Copy it before next step)
- @_resetCode
- Use Reset Code
+ Your reset code is on the server console.
+ Use Reset Code
}
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor.cs
index 9ca66fc3..96e81a55 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor.cs
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor.cs
@@ -9,7 +9,6 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs;
public partial class ResetDialog
{
private bool _resetRequested = false;
- private string? _resetCode;
private string? _identifier;
[CascadingParameter]
@@ -34,7 +33,6 @@ private async Task RequestResetAsync()
return;
}
- _resetCode = result.Value.Token;
_resetRequested = true;
}
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DevelopmentCredentialResetNotifier.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DevelopmentCredentialResetNotifier.cs
new file mode 100644
index 00000000..db379e8f
--- /dev/null
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DevelopmentCredentialResetNotifier.cs
@@ -0,0 +1,21 @@
+using CodeBeam.UltimateAuth.Credentials;
+
+namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure;
+
+internal sealed class DevelopmentCredentialResetNotifier(ILogger logger) : ICredentialResetNotifier
+{
+ public Task NotifyAsync(CredentialResetNotification notification, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ logger.LogWarning(
+ "DEVELOPMENT ONLY — Credential reset for tenant {Tenant}, " +
+ "user {UserKey}. Code: {Code}. Expires at: {ExpiresAt}",
+ notification.Tenant,
+ notification.UserKey,
+ notification.Token,
+ notification.ExpiresAt);
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs
index b939d487..fb79b4a0 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs
@@ -3,6 +3,7 @@
using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.Infrastructure;
+using CodeBeam.UltimateAuth.Credentials;
using CodeBeam.UltimateAuth.InMemory;
using CodeBeam.UltimateAuth.Sample.BlazorServer.Components;
using CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure;
@@ -63,6 +64,11 @@
builder.Services.AddUltimateAuthSampleSeed();
+if (builder.Environment.IsDevelopment())
+{
+ builder.Services.AddScoped();
+}
+
builder.Services.Configure(options =>
{
options.ForwardedHeaders =
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor
index 06a515aa..8d4c90d3 100644
--- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor
@@ -25,8 +25,7 @@
Request Reset
@if (_resetRequested)
{
- Your reset code is: (Copy it before next step)
- @_resetCode
+ Your reset code is on the server console.
Use Reset Code
}
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor.cs
index 977d1e38..7dab5f66 100644
--- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor.cs
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor.cs
@@ -9,7 +9,6 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Components.Dialogs;
public partial class ResetDialog
{
private bool _resetRequested = false;
- private string? _resetCode;
private string? _identifier;
[CascadingParameter]
@@ -34,7 +33,6 @@ private async Task RequestResetAsync()
return;
}
- _resetCode = result.Value.Token;
_resetRequested = true;
}
diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor
index 06a515aa..8d4c90d3 100644
--- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor
+++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor
@@ -25,8 +25,7 @@
Request Reset
@if (_resetRequested)
{
- Your reset code is: (Copy it before next step)
- @_resetCode
+ Your reset code is on the server console.
Use Reset Code
}
diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor.cs b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor.cs
index 04657723..17ff4866 100644
--- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor.cs
+++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor.cs
@@ -9,7 +9,6 @@ namespace CodeBeam.UAuth.Sample.IntWasm.Client.Components.Dialogs;
public partial class ResetDialog
{
private bool _resetRequested = false;
- private string? _resetCode;
private string? _identifier;
[CascadingParameter]
@@ -34,7 +33,6 @@ private async Task RequestResetAsync()
return;
}
- _resetCode = result.Value.Token;
_resetRequested = true;
}
diff --git a/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs b/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs
index d3c09386..247b2327 100644
--- a/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs
+++ b/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs
@@ -1,4 +1,5 @@
using System.Runtime.CompilerServices;
+[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Credentials.Reference")]
[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")]
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]
diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs
index 690e5e98..dfa550da 100644
--- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs
@@ -358,6 +358,7 @@ internal static IServiceCollection AddUsersInternal(IServiceCollection services)
internal static IServiceCollection AddCredentialsInternal(IServiceCollection services)
{
services.TryAddScoped();
+ services.TryAddScoped();
return services;
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/NotConfiguredCredentialResetNotifier.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/NotConfiguredCredentialResetNotifier.cs
new file mode 100644
index 00000000..2215e023
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/NotConfiguredCredentialResetNotifier.cs
@@ -0,0 +1,20 @@
+using CodeBeam.UltimateAuth.Credentials;
+
+namespace CodeBeam.UltimateAuth.Server.Infrastructure;
+
+internal sealed class NotConfiguredCredentialResetNotifier : ICredentialResetNotifier
+{
+ internal static InvalidOperationException CreateException() => new(
+ "Credential reset notification is not configured. " +
+ "Implement ICredentialResetNotifier and register it with " +
+ "services.AddScoped(). " +
+ "The implementation must send the reset code or token " +
+ "through a trusted channel associated with the user's account.");
+
+ public Task NotifyAsync(CredentialResetNotification context, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+ throw CreateException();
+ }
+}
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs
index c7f399c8..db8d2c8c 100644
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs
@@ -2,6 +2,5 @@
public sealed record BeginCredentialResetResult
{
- public string? Token { get; init; }
public DateTimeOffset ExpiresAt { get; init; }
}
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs
index 71d051d6..95735d6e 100644
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs
@@ -24,6 +24,7 @@ internal sealed class CredentialManagementService : ICredentialManagementService
private readonly ITokenHasher _tokenHasher;
private readonly ILoginIdentifierResolver _identifierResolver;
private readonly ISessionStoreFactory _sessionFactory;
+ private readonly ICredentialResetNotifier _resetNotifier;
private readonly UAuthServerOptions _options;
private readonly IClock _clock;
@@ -37,6 +38,7 @@ public CredentialManagementService(
ITokenHasher tokenHasher,
ILoginIdentifierResolver identifierResolver,
ISessionStoreFactory sessionFactory,
+ ICredentialResetNotifier resetNotifier,
IOptions options,
IClock clock)
{
@@ -49,6 +51,7 @@ public CredentialManagementService(
_tokenHasher = tokenHasher;
_identifierResolver = identifierResolver;
_sessionFactory = sessionFactory;
+ _resetNotifier = resetNotifier;
_options = options.Value;
_clock = clock;
}
@@ -199,22 +202,28 @@ public async Task BeginResetAsync(AccessContext cont
var cmd = new AccessCommand(async innerCt =>
{
+
+ if (_resetNotifier is NotConfiguredCredentialResetNotifier)
+ throw NotConfiguredCredentialResetNotifier.CreateException();
+
if (string.IsNullOrWhiteSpace(request.Identifier))
throw new UAuthValidationException("identifier_required");
var now = _clock.UtcNow;
- var validity = request.Validity ?? _options.ResetCredential.TokenValidity;
+ // Anonymous callers must not control the proof's lifetime.
+ var validity = _options.ResetCredential.TokenValidity;
+ var expiresAt = now.Add(validity);
+
+ var result = new BeginCredentialResetResult
+ {
+ ExpiresAt = expiresAt
+ };
+
var resolution = await _identifierResolver.ResolveAsync(context.ResourceTenant, request.Identifier, innerCt);
if (resolution?.UserKey is not UserKey userKey)
- {
- return new BeginCredentialResetResult
- {
- Token = null,
- ExpiresAt = now.Add(validity)
- };
- }
+ return result; // Enumeration protection
var state = await _authenticationSecurityManager
.GetOrCreateFactorAsync(context.ResourceTenant, userKey, request.CredentialType, innerCt);
@@ -239,11 +248,17 @@ public async Task BeginResetAsync(AccessContext cont
var updatedState = state.BeginReset(tokenHash, now, validity);
await _authenticationSecurityManager.UpdateAsync(updatedState, state.SecurityVersion, innerCt);
- return new BeginCredentialResetResult
- {
- Token = token,
- ExpiresAt = now.Add(validity)
- };
+ await _resetNotifier.NotifyAsync(
+ new CredentialResetNotification(
+ Tenant: context.ResourceTenant,
+ UserKey: userKey,
+ CredentialType: request.CredentialType,
+ CodeType: request.ResetCodeType,
+ Token: token,
+ ExpiresAt: expiresAt),
+ innerCt);
+
+ return result;
});
return await _accessOrchestrator.ExecuteAsync(context, cmd, ct);
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialResetNotifier.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialResetNotifier.cs
new file mode 100644
index 00000000..d6ca6c3c
--- /dev/null
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialResetNotifier.cs
@@ -0,0 +1,17 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+
+namespace CodeBeam.UltimateAuth.Credentials;
+
+public interface ICredentialResetNotifier
+{
+ Task NotifyAsync(CredentialResetNotification notification, CancellationToken ct = default);
+}
+
+public sealed record CredentialResetNotification(
+ TenantKey Tenant,
+ UserKey UserKey,
+ CredentialType CredentialType,
+ ResetCodeType CodeType,
+ string Token,
+ DateTimeOffset ExpiresAt);
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs
index 601b268e..b6f2bf97 100644
--- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs
@@ -5,6 +5,7 @@
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.Errors;
using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Credentials;
using CodeBeam.UltimateAuth.Credentials.Contracts;
using CodeBeam.UltimateAuth.Credentials.Reference;
using CodeBeam.UltimateAuth.Credentials.Reference.Internal;
@@ -746,6 +747,9 @@ private sealed class Fixture
public Mock IdentifierResolver { get; }
= new(MockBehavior.Strict);
+ public Mock ResetNotifier { get; }
+ = new(MockBehavior.Strict);
+
public Mock SessionStoreFactory { get; }
= new(MockBehavior.Strict);
@@ -830,6 +834,7 @@ public Fixture()
TokenHasher.Object,
IdentifierResolver.Object,
SessionStoreFactory.Object,
+ ResetNotifier.Object,
options,
Clock.Object);
}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs
index fced32ec..99785fda 100644
--- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs
@@ -1,98 +1,106 @@
using CodeBeam.UltimateAuth.Core.Defaults;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.Errors;
+using CodeBeam.UltimateAuth.Credentials;
using CodeBeam.UltimateAuth.Credentials.Contracts;
using CodeBeam.UltimateAuth.Tests.Unit.Helpers;
using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using System.Text.Json;
namespace CodeBeam.UltimateAuth.Tests.Unit;
public class ResetPasswordTests
{
[Fact]
- public async Task Begin_reset_with_token_should_return_token()
+ public async Task Begin_reset_with_token_should_notify_without_exposing_token()
{
- var runtime = new TestAuthRuntime();
+ var (runtime, notifier) = CreateRuntime();
var service = runtime.GetCredentialManagementService();
- var context = TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous);
+ var result = await service.BeginResetAsync(
+ BeginContext(),
+ BeginRequest(ResetCodeType.Token));
- var result = await service.BeginResetAsync(context,
- new BeginResetCredentialRequest
- {
- Identifier = "admin",
- CredentialType = CredentialType.Password,
- ResetCodeType = ResetCodeType.Token
- });
+ var notification = notifier.Notifications.Should()
+ .ContainSingle().Subject;
- result.Token.Should().NotBeNull();
- result.Token!.Length.Should().BeGreaterThan(20);
- result.ExpiresAt.Should().BeAfter(DateTimeOffset.UtcNow);
- }
+ notification.Token.Should().NotBeNullOrWhiteSpace();
+ notification.Token.Length.Should().BeGreaterThan(20);
+ notification.CodeType.Should().Be(ResetCodeType.Token);
+ notification.CredentialType.Should().Be(CredentialType.Password);
+ notification.ExpiresAt.Should().Be(result.ExpiresAt);
+ result.ExpiresAt.Should().BeAfter(runtime.Clock.UtcNow);
- [Fact]
- public async Task Begin_reset_with_code_should_return_numeric_code()
- {
- var runtime = new TestAuthRuntime();
- var service = runtime.GetCredentialManagementService();
+ // The public JSON response must not contain the reset proof.
+ using var json = JsonDocument.Parse(JsonSerializer.Serialize(result, new JsonSerializerOptions(JsonSerializerDefaults.Web)));
+
+ json.RootElement.TryGetProperty("token", out _).Should().BeFalse();
+ JsonSerializer.Serialize(result)
+ .Should().NotContain(notification.Token);
- var context = TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous);
+ var json1 = JsonSerializer.Serialize(result, new JsonSerializerOptions(JsonSerializerDefaults.Web));
- var result = await service.BeginResetAsync(context,
- new BeginResetCredentialRequest
- {
- Identifier = "admin",
- CredentialType = CredentialType.Password,
- ResetCodeType = ResetCodeType.Code
- });
+ using var document = JsonDocument.Parse(json1);
- result.Token.Should().NotBeNull();
- result.Token!.Should().MatchRegex("^[0-9]{6}$");
+ document.RootElement
+ .EnumerateObject()
+ .Select(property => property.Name)
+ .Should()
+ .BeEquivalentTo(
+ new[] { "expiresAt" },
+ "public reset response must contain only the approved fields");
+
+ json1.Should().NotContain(
+ notification.Token,
+ "the reset proof must be delivered only through the notifier");
}
[Fact]
- public async Task Begin_reset_for_unknown_user_should_not_fail()
+ public async Task Begin_reset_with_code_should_notify_numeric_code()
{
- var runtime = new TestAuthRuntime();
- var service = runtime.GetCredentialManagementService();
+ var (runtime, notifier) = CreateRuntime();
- var context = TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous);
+ await runtime.GetCredentialManagementService().BeginResetAsync(
+ BeginContext(),
+ BeginRequest(ResetCodeType.Code));
- var result = await service.BeginResetAsync(context,
- new BeginResetCredentialRequest
- {
- Identifier = "unknown@test.com",
- CredentialType = CredentialType.Password,
- ResetCodeType = ResetCodeType.Token
- });
+ var notification = notifier.Notifications.Should()
+ .ContainSingle().Subject;
+
+ notification.CodeType.Should().Be(ResetCodeType.Code);
+ notification.Token.Should().MatchRegex("^[0-9]{6}$");
+ }
- result.Token.Should().BeNull();
+ [Fact]
+ public async Task Begin_reset_for_unknown_user_should_not_notify()
+ {
+ var (runtime, notifier) = CreateRuntime();
+
+ var result = await runtime.GetCredentialManagementService()
+ .BeginResetAsync(
+ BeginContext(),
+ BeginRequest(
+ ResetCodeType.Token,
+ "unknown@test.com"));
+
+ result.Should().NotBeNull();
+ result.ExpiresAt.Should().BeAfter(runtime.Clock.UtcNow);
+ notifier.Notifications.Should().BeEmpty();
}
[Fact]
public async Task Reset_password_with_valid_token_should_succeed()
{
- var runtime = new TestAuthRuntime();
+ var (runtime, notifier) = CreateRuntime();
var service = runtime.GetCredentialManagementService();
- var begin = await service.BeginResetAsync(
- TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous),
- new BeginResetCredentialRequest
- {
- Identifier = "admin",
- CredentialType = CredentialType.Password,
- ResetCodeType = ResetCodeType.Token
- });
+ var token = await BeginAndCaptureAsync(
+ runtime, notifier, ResetCodeType.Token);
var result = await service.CompleteResetAsync(
- TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous),
- new CompleteResetCredentialRequest
- {
- Identifier = "admin",
- CredentialType = CredentialType.Password,
- ResetToken = begin.Token!,
- NewSecret = "newpass123"
- });
+ CompleteContext(),
+ CompleteRequest(token, "newpass123"));
result.Succeeded.Should().BeTrue();
}
@@ -100,28 +108,16 @@ public async Task Reset_password_with_valid_token_should_succeed()
[Fact]
public async Task Reset_password_with_same_password_should_fail()
{
- var runtime = new TestAuthRuntime();
+ var (runtime, notifier) = CreateRuntime();
var service = runtime.GetCredentialManagementService();
- var begin = await service.BeginResetAsync(
- TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous),
- new BeginResetCredentialRequest
- {
- Identifier = "admin",
- CredentialType = CredentialType.Password,
- ResetCodeType = ResetCodeType.Token
- });
+ var token = await BeginAndCaptureAsync(
+ runtime, notifier, ResetCodeType.Token);
Func act = async () =>
await service.CompleteResetAsync(
- TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous),
- new CompleteResetCredentialRequest
- {
- Identifier = "admin",
- CredentialType = CredentialType.Password,
- ResetToken = begin.Token!,
- NewSecret = "admin"
- });
+ CompleteContext(),
+ CompleteRequest(token, "admin"));
await act.Should().ThrowAsync();
}
@@ -129,45 +125,31 @@ await service.CompleteResetAsync(
[Fact]
public async Task Reset_token_should_lock_after_max_attempts()
{
- var runtime = new TestAuthRuntime();
+ const int maxAttempts = 3;
+ var (runtime, notifier) = CreateRuntime(maxAttempts);
var service = runtime.GetCredentialManagementService();
- var begin = await service.BeginResetAsync(
- TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous),
- new BeginResetCredentialRequest
- {
- Identifier = "admin",
- CredentialType = CredentialType.Password,
- ResetCodeType = ResetCodeType.Code
- });
+ var token = await BeginAndCaptureAsync(
+ runtime, notifier, ResetCodeType.Code);
+
+ var wrongToken = token == "000000" ? "000001" : "000000";
- for (int i = 0; i < 3; i++)
+ for (var i = 0; i < maxAttempts; i++)
{
- try
- {
+ Func invalidAttempt = async () =>
await service.CompleteResetAsync(
- TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous),
- new CompleteResetCredentialRequest
- {
- Identifier = "admin",
- CredentialType = CredentialType.Password,
- ResetToken = begin!.Token == "000000" ? "000001" : "000000",
- NewSecret = "newpass123"
- });
- }
- catch { }
+ CompleteContext(),
+ CompleteRequest(wrongToken, "newpass123"));
+
+ // Do not swallow unrelated failures.
+ await invalidAttempt.Should()
+ .ThrowAsync();
}
Func act = async () =>
await service.CompleteResetAsync(
- TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous),
- new CompleteResetCredentialRequest
- {
- Identifier = "admin",
- CredentialType = CredentialType.Password,
- ResetToken = begin.Token!,
- NewSecret = "newpass123"
- });
+ CompleteContext(),
+ CompleteRequest(token, "newpass123"));
await act.Should().ThrowAsync();
}
@@ -175,38 +157,22 @@ await service.CompleteResetAsync(
[Fact]
public async Task Reset_token_should_be_single_use()
{
- var runtime = new TestAuthRuntime();
+ var (runtime, notifier) = CreateRuntime();
var service = runtime.GetCredentialManagementService();
- var begin = await service.BeginResetAsync(
- TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous),
- new BeginResetCredentialRequest
- {
- Identifier = "admin",
- CredentialType = CredentialType.Password,
- ResetCodeType = ResetCodeType.Token
- });
-
- await service.CompleteResetAsync(
- TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous),
- new CompleteResetCredentialRequest
- {
- Identifier = "admin",
- CredentialType = CredentialType.Password,
- ResetToken = begin.Token!,
- NewSecret = "newpass123"
- });
+ var token = await BeginAndCaptureAsync(
+ runtime, notifier, ResetCodeType.Token);
+
+ var first = await service.CompleteResetAsync(
+ CompleteContext(),
+ CompleteRequest(token, "newpass123"));
+
+ first.Succeeded.Should().BeTrue();
Func act = async () =>
await service.CompleteResetAsync(
- TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous),
- new CompleteResetCredentialRequest
- {
- Identifier = "admin",
- CredentialType = CredentialType.Password,
- ResetToken = begin.Token!,
- NewSecret = "anotherpass"
- });
+ CompleteContext(),
+ CompleteRequest(token, "anotherpass"));
await act.Should().ThrowAsync();
}
@@ -214,32 +180,97 @@ await service.CompleteResetAsync(
[Fact]
public async Task Reset_token_should_fail_if_expired()
{
- var runtime = new TestAuthRuntime();
+ var (runtime, notifier) = CreateRuntime();
var service = runtime.GetCredentialManagementService();
- var clock = runtime.Clock;
- var begin = await service.BeginResetAsync(
- TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous),
- new BeginResetCredentialRequest
- {
- Identifier = "admin",
- CredentialType = CredentialType.Password,
- ResetCodeType = ResetCodeType.Token
- });
+ var token = await BeginAndCaptureAsync(
+ runtime, notifier, ResetCodeType.Token);
+
+ var notification = notifier.Notifications.Single();
- clock.Advance(TimeSpan.FromMinutes(45));
+ // Advance past the actual configured expiry.
+ runtime.Clock.Advance(
+ notification.ExpiresAt - runtime.Clock.UtcNow
+ + TimeSpan.FromSeconds(1));
Func act = async () =>
await service.CompleteResetAsync(
- TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous),
- new CompleteResetCredentialRequest
- {
- Identifier = "admin",
- CredentialType = CredentialType.Password,
- ResetToken = begin.Token!,
- NewSecret = "newpass123"
- });
+ CompleteContext(),
+ CompleteRequest(token, "newpass123"));
await act.Should().ThrowAsync();
}
+
+ private static (
+ TestAuthRuntime Runtime,
+ RecordingCredentialResetNotifier Notifier)
+ CreateRuntime(int maxAttempts = 3)
+ {
+ var notifier = new RecordingCredentialResetNotifier();
+
+ var runtime = new TestAuthRuntime(
+ configureServer: options =>
+ options.ResetCredential.MaxAttempts = maxAttempts,
+ configureServices: services =>
+ services.AddSingleton(notifier));
+
+ return (runtime, notifier);
+ }
+
+ private static async Task BeginAndCaptureAsync(
+ TestAuthRuntime runtime,
+ RecordingCredentialResetNotifier notifier,
+ ResetCodeType codeType)
+ {
+ await runtime.GetCredentialManagementService().BeginResetAsync(
+ BeginContext(),
+ BeginRequest(codeType));
+
+ return notifier.Notifications.Should()
+ .ContainSingle().Subject.Token;
+ }
+
+ private static CodeBeam.UltimateAuth.Core.Contracts.AccessContext
+ BeginContext() =>
+ TestAccessContext.WithAction(
+ UAuthActions.Credentials.BeginResetAnonymous);
+
+ private static CodeBeam.UltimateAuth.Core.Contracts.AccessContext
+ CompleteContext() =>
+ TestAccessContext.WithAction(
+ UAuthActions.Credentials.CompleteResetAnonymous);
+
+ private static BeginResetCredentialRequest BeginRequest(
+ ResetCodeType codeType,
+ string identifier = "admin") => new()
+ {
+ Identifier = identifier,
+ CredentialType = CredentialType.Password,
+ ResetCodeType = codeType
+ };
+
+ private static CompleteResetCredentialRequest CompleteRequest(
+ string token,
+ string newSecret) => new()
+ {
+ Identifier = "admin",
+ CredentialType = CredentialType.Password,
+ ResetToken = token,
+ NewSecret = newSecret
+ };
+
+ private sealed class RecordingCredentialResetNotifier
+ : ICredentialResetNotifier
+ {
+ public List Notifications { get; } = [];
+
+ public Task NotifyAsync(
+ CredentialResetNotification notification,
+ CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+ Notifications.Add(notification);
+ return Task.CompletedTask;
+ }
+ }
}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs
index b602f544..272c7a99 100644
--- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs
@@ -28,7 +28,7 @@ internal sealed class TestAuthRuntime where TUserId : notnull
public IServiceProvider Services { get; }
public TestClock Clock { get; }
- public TestAuthRuntime(Action? configureServer = null, Action? configureCore = null)
+ public TestAuthRuntime(Action? configureServer = null, Action? configureCore = null, Action? configureServices = null)
{
Clock = new TestClock();
var services = new ServiceCollection();
@@ -53,6 +53,8 @@ public TestAuthRuntime(Action? configureServer = null, Actio
services.AddSingleton(configuration);
services.AddSingleton(Clock);
+ configureServices?.Invoke(services);
+
Services = services.BuildServiceProvider();
using (var scope = Services.CreateScope())