diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs
index d30489d9..20e0316d 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs
@@ -149,11 +149,7 @@ public AuthenticationSecurityState ResetFailuresIfWindowExpired(DateTimeOffset n
securityVersion: SecurityVersion + 1);
}
- ///
- /// Registers a failed authentication attempt. Optionally locks until now + duration when threshold reached.
- /// If already locked, may extend lock depending on extendLock.
- ///
- public AuthenticationSecurityState RegisterFailure(DateTimeOffset now, int threshold, TimeSpan lockoutDuration, bool extendLock = true)
+ public AuthenticationSecurityState RegisterFailure(DateTimeOffset now, int threshold, TimeSpan lockoutDuration, TimeSpan failureWindow, bool extendLock = true)
{
if (threshold < 0)
throw new UAuthValidationException(nameof(threshold));
@@ -161,12 +157,21 @@ public AuthenticationSecurityState RegisterFailure(DateTimeOffset now, int thres
var effectiveFailedAttempts = FailedAttempts;
var effectiveLockedUntil = LockedUntil;
+ // Existing lock expired.
if (effectiveLockedUntil.HasValue && now >= effectiveLockedUntil.Value)
{
effectiveFailedAttempts = 0;
effectiveLockedUntil = null;
}
+ // Previous failure sequence expired.
+ if (failureWindow > TimeSpan.Zero &&
+ LastFailedAt is DateTimeOffset lastFailedAt &&
+ now - lastFailedAt > failureWindow)
+ {
+ effectiveFailedAttempts = 0;
+ }
+
var nextCount = effectiveFailedAttempts + 1;
DateTimeOffset? nextLockedUntil = effectiveLockedUntil;
diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs
index 29a97238..fe13ec65 100644
--- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs
@@ -1,4 +1,5 @@
-using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Domain;
namespace CodeBeam.UltimateAuth.Server.Flows;
@@ -8,6 +9,13 @@ namespace CodeBeam.UltimateAuth.Server.Flows;
///
public sealed class LoginAuthority : ILoginAuthority
{
+ private readonly IClock _clock;
+
+ public LoginAuthority(IClock clock)
+ {
+ _clock = clock;
+ }
+
public LoginDecision Decide(LoginDecisionContext context)
{
if (!context.UserExists || context.UserKey is null)
@@ -18,7 +26,7 @@ public LoginDecision Decide(LoginDecisionContext context)
var state = context.SecurityState;
if (state is not null)
{
- if (state.IsLocked(DateTimeOffset.UtcNow))
+ if (state.IsLocked(_clock.UtcNow))
return LoginDecision.Deny(AuthFailureReason.LockedOut);
if (state.RequiresReauthentication)
diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs
index 1b353d97..6f93c1f2 100644
--- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs
@@ -166,7 +166,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req
if (!loginExecution.SuppressFailureAttempt)
{
var version = factorState.SecurityVersion;
- factorState = factorState.RegisterFailure(now, _options.Login.MaxFailedAttempts, _options.Login.LockoutDuration, _options.Login.ExtendLockOnFailure);
+ factorState = factorState.RegisterFailure(now, _options.Login.MaxFailedAttempts, _options.Login.LockoutDuration, _options.Login.FailureWindow, _options.Login.ExtendLockOnFailure);
await _authenticationSecurityManager.UpdateAsync(factorState, version, ct);
}
@@ -259,8 +259,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req
};
}
- await _events.DispatchAsync(
- new UserLoggedInContext(flow.Tenant, userKey.Value, now, flow.Device, issuedSession.Session.SessionId));
+ await _events.DispatchAsync(new UserLoggedInContext(flow.Tenant, userKey.Value, now, flow.Device, issuedSession.Session.SessionId));
return LoginResult.Success(issuedSession.Session.SessionId, tokens);
}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerFactory.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerFactory.cs
index ebc06239..0359c345 100644
--- a/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerFactory.cs
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerFactory.cs
@@ -1,12 +1,112 @@
-using Microsoft.AspNetCore.Hosting;
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Credentials.Contracts;
+using CodeBeam.UltimateAuth.Credentials.Reference;
+using CodeBeam.UltimateAuth.Server.Infrastructure;
+using CodeBeam.UltimateAuth.Users.Contracts;
+using CodeBeam.UltimateAuth.Users.Reference;
+using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
namespace CodeBeam.UltimateAuth.Tests.Integration;
public class AuthServerFactory : WebApplicationFactory
{
+ public IntegrationTestClock Clock { get; } = new();
+
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
+
+ builder.ConfigureServices(services =>
+ {
+ services.RemoveAll();
+ services.AddSingleton(Clock);
+ });
+ }
+
+ internal async Task CreateLoginUserAsync(
+ string? identifier = null,
+ string? secret = null,
+ CancellationToken ct = default)
+ {
+ using var scope = Services.CreateScope();
+
+ var services = scope.ServiceProvider;
+
+ var lifecycleFactory =
+ services.GetRequiredService();
+
+ var identifierFactory =
+ services.GetRequiredService();
+
+ var credentialFactory =
+ services.GetRequiredService();
+
+ var normalizer =
+ services.GetRequiredService();
+
+ var hasher =
+ services.GetRequiredService();
+
+ var clock =
+ services.GetRequiredService();
+
+ var tenant = TenantKeys.Single;
+ var userKey = UserKey.New();
+
+ identifier ??= $"test-{Guid.NewGuid():N}";
+ secret ??= $"Test-{Guid.NewGuid():N}!";
+
+ var now = clock.UtcNow;
+
+ var lifecycleStore = lifecycleFactory.Create(tenant);
+ var identifierStore = identifierFactory.Create(tenant);
+ var credentialStore = credentialFactory.Create(tenant);
+
+ await lifecycleStore.AddAsync(
+ UserLifecycle.Create(
+ tenant,
+ userKey,
+ now),
+ ct);
+
+ var normalized = normalizer
+ .Normalize(
+ UserIdentifierType.Username,
+ identifier)
+ .Normalized;
+
+ await identifierStore.AddAsync(
+ UserIdentifier.Create(
+ Guid.NewGuid(),
+ tenant,
+ userKey,
+ UserIdentifierType.Username,
+ identifier,
+ normalized,
+ now,
+ isPrimary: true,
+ verifiedAt: now),
+ ct);
+
+ await credentialStore.AddAsync(
+ PasswordCredential.Create(
+ Guid.NewGuid(),
+ tenant,
+ userKey,
+ hasher.Hash(secret),
+ CredentialSecurityState.Active(),
+ new CredentialMetadata(),
+ now),
+ ct);
+
+ return new IntegrationTestUser(
+ userKey,
+ identifier,
+ secret);
}
}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/IntegrationTestClock.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/IntegrationTestClock.cs
new file mode 100644
index 00000000..ad56dd32
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/IntegrationTestClock.cs
@@ -0,0 +1,48 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+
+namespace CodeBeam.UltimateAuth.Tests.Integration;
+
+public sealed class IntegrationTestClock : IClock
+{
+ private readonly object _sync = new();
+
+ private DateTimeOffset _utcNow = new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero);
+
+ public DateTimeOffset UtcNow
+ {
+ get
+ {
+ lock (_sync)
+ {
+ return _utcNow;
+ }
+ }
+ }
+
+ public void Advance(TimeSpan duration)
+ {
+ if (duration < TimeSpan.Zero)
+ throw new ArgumentOutOfRangeException(nameof(duration));
+
+ lock (_sync)
+ {
+ _utcNow = _utcNow.Add(duration);
+ }
+ }
+
+ public void Set(DateTimeOffset value)
+ {
+ lock (_sync)
+ {
+ _utcNow = value.ToUniversalTime();
+ }
+ }
+
+ public void Reset()
+ {
+ lock (_sync)
+ {
+ _utcNow = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero);
+ }
+ }
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/IntegrationTestUser.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/IntegrationTestUser.cs
new file mode 100644
index 00000000..2392f2d4
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/IntegrationTestUser.cs
@@ -0,0 +1,8 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Tests.Integration;
+
+internal sealed record IntegrationTestUser(
+ UserKey UserKey,
+ string Identifier,
+ string Secret);
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs
index 5d8a86af..b5d28b00 100644
--- a/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs
@@ -1,4 +1,6 @@
-using CodeBeam.UltimateAuth.Users.Contracts;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Users.Contracts;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using System.Net;
@@ -8,90 +10,1902 @@ namespace CodeBeam.UltimateAuth.Tests.Integration;
public class LoginTests : IClassFixture
{
- private readonly HttpClient _client;
+ private const string LoginEndpoint = "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/auth/login";
+ private const string ProfileEndpoint = "/auth/me/profile/get";
+
+ private const string ValidIdentifier = "admin";
+ private const string ValidSecret = "admin";
+
+ private readonly AuthServerFactory _factory;
public LoginTests(AuthServerFactory factory)
{
- _client = factory.CreateClient(new WebApplicationFactoryClientOptions
- {
- AllowAutoRedirect = false,
- HandleCookies = false
- });
-
- _client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130");
- _client.DefaultRequestHeaders.Add("X-UDID", "test-device-1234567890123456");
+ _factory = factory;
}
[Fact]
- public async Task Login_Should_Return_Cookie()
+ public async Task Login_WithValidCredentials_ShouldIssueSessionCredential()
{
- var response = await _client.PostAsJsonAsync("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/auth/login", new
- {
- identifier = "admin",
- secret = "admin"
- });
+ var user = await _factory.CreateLoginUserAsync();
+
+ using var client = CreateClient(
+ $"valid-session-{Guid.NewGuid():N}");
+
+ var response = await client.PostAsJsonAsync(
+ "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/auth/login",
+ new
+ {
+ identifier = user.Identifier,
+ secret = user.Secret
+ });
response.StatusCode.Should().Be(HttpStatusCode.Found);
- response.Headers.Location.Should().NotBeNull();
- response.Headers.TryGetValues("Set-Cookie", out var cookies).Should().BeTrue();
- cookies.Should().NotBeNull();
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out var cookies)
+ .Should().BeTrue();
+
+ cookies.Should().NotBeNullOrEmpty();
+
+ var cookie = GetSessionCookie(response);
+
+ cookie.Should().NotBeNullOrWhiteSpace();
}
[Fact]
- public async Task Session_Lifecycle_Should_Work_Correctly()
+ public async Task Login_WithValidCredentials_ShouldCreateUsableAuthenticatedSession()
{
- var loginResponse1 = await _client.PostAsJsonAsync("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/auth/login", new
- {
- identifier = "admin",
- secret = "admin"
- });
+ var user = await _factory.CreateLoginUserAsync();
+
+ var deviceId = $"usable-session-{Guid.NewGuid():N}";
+
+ using var client = CreateClient(deviceId);
+
+ var loginResponse = await LoginAsync(
+ client,
+ user.Identifier,
+ user.Secret);
+
+ loginResponse.StatusCode.Should().Be(HttpStatusCode.Found);
+
+ var cookie = GetSessionCookie(loginResponse);
+
+ using var authenticatedClient = CreateClient(deviceId);
+
+ authenticatedClient.DefaultRequestHeaders.Add(
+ "Cookie",
+ cookie);
+
+ var response = await authenticatedClient.PostAsJsonAsync(
+ "/auth/me/sessions/chains",
+ new PageRequest
+ {
+ PageNumber = 1,
+ PageSize = 10
+ });
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var result =
+ await response.Content.ReadFromJsonAsync>();
+
+ result.Should().NotBeNull();
+ result!.Items.Should().NotBeEmpty();
+
+ var currentChain = result.Items.Single(x => x.IsCurrentDevice);
+
+ currentChain.ActiveSessionId.Should().NotBeNull();
+ currentChain.IsRevoked.Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task Login_WithInvalidPassword_ShouldNotAuthenticateUser()
+ {
+ using var client = CreateClient();
+
+ var response = await LoginAsync(
+ client,
+ ValidIdentifier,
+ "wrong-password");
+
+ response.StatusCode.Should().BeOneOf(
+ HttpStatusCode.Unauthorized,
+ HttpStatusCode.Found);
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task Login_WithUnknownIdentifier_ShouldNotAuthenticateUser()
+ {
+ using var client = CreateClient();
+
+ var response = await LoginAsync(
+ client,
+ "unknown-user",
+ ValidSecret);
+
+ response.StatusCode.Should().BeOneOf(
+ HttpStatusCode.Unauthorized,
+ HttpStatusCode.Found);
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+ }
+
+ [Theory]
+ [InlineData("", "admin")]
+ [InlineData(" ", "admin")]
+ [InlineData("admin", "")]
+ [InlineData("admin", " ")]
+ public async Task Login_WithMissingCredentials_ShouldNotAuthenticateUser(
+ string identifier,
+ string secret)
+ {
+ using var client = CreateClient();
+
+ var response = await LoginAsync(
+ client,
+ identifier,
+ secret);
+
+ response.StatusCode.Should().BeOneOf(
+ HttpStatusCode.Unauthorized,
+ HttpStatusCode.Found);
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task Login_WithUnsupportedContentType_ShouldNotAuthenticateUser()
+ {
+ using var client = CreateClient();
+
+ using var content = new StringContent(
+ "identifier=admin&secret=admin");
+
+ var response = await client.PostAsync(
+ LoginEndpoint,
+ content);
+
+ response.StatusCode.Should().BeOneOf(
+ HttpStatusCode.Unauthorized,
+ HttpStatusCode.Found);
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task Login_WithInvalidPassword_ShouldNotIssueSessionCredential()
+ {
+ using var client = CreateClient();
+
+ var response = await LoginAsync(
+ client,
+ ValidIdentifier,
+ "definitely-wrong-password");
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+ }
- loginResponse1.StatusCode.Should().Be(HttpStatusCode.Found);
+ [Fact]
+ public async Task Login_WithUnknownIdentifier_ShouldBehaveLikeInvalidPassword()
+ {
+ using var invalidPasswordClient = CreateClient(
+ "enumeration-device-1111111111111111");
+
+ using var unknownUserClient = CreateClient(
+ "enumeration-device-2222222222222222");
+
+ var invalidPasswordResponse = await LoginAsync(
+ invalidPasswordClient,
+ ValidIdentifier,
+ "definitely-wrong-password");
+
+ var unknownUserResponse = await LoginAsync(
+ unknownUserClient,
+ "user-that-does-not-exist",
+ "definitely-wrong-password");
+
+ invalidPasswordResponse.StatusCode
+ .Should()
+ .Be(unknownUserResponse.StatusCode);
+
+ invalidPasswordResponse.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ unknownUserResponse.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task Login_AfterMaximumFailedAttempts_ShouldRejectCorrectPassword()
+ {
+ using var client = CreateClient(
+ "lockout-device-111111111111111111");
+
+ var firstFailure = await LoginAsync(
+ client,
+ ValidIdentifier,
+ "wrong-password-1");
+
+ firstFailure.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ var secondFailure = await LoginAsync(
+ client,
+ ValidIdentifier,
+ "wrong-password-2");
+
+ secondFailure.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ var correctPasswordDuringLockout = await LoginAsync(
+ client,
+ ValidIdentifier,
+ ValidSecret);
+
+ correctPasswordDuringLockout.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ correctPasswordDuringLockout.StatusCode.Should().BeOneOf(
+ HttpStatusCode.Unauthorized,
+ HttpStatusCode.Found);
+ }
+
+ [Fact]
+ public async Task Login_AfterLockoutExpires_ShouldAllowCorrectCredentials()
+ {
+ _factory.Clock.Reset();
+
+ var user = await _factory.CreateLoginUserAsync();
+
+ using var client = CreateClient(
+ $"lockout-expiry-{Guid.NewGuid():N}");
+
+ await LoginAsync(
+ client,
+ user.Identifier,
+ "wrong-password-1");
+
+ await LoginAsync(
+ client,
+ user.Identifier,
+ "wrong-password-2");
+
+ var duringLockout = await LoginAsync(
+ client,
+ user.Identifier,
+ user.Secret);
+
+ duringLockout.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ _factory.Clock.Advance(
+ TimeSpan.FromSeconds(11));
+
+ var afterLockout = await LoginAsync(
+ client,
+ user.Identifier,
+ user.Secret);
+
+ afterLockout.StatusCode.Should()
+ .Be(HttpStatusCode.Found);
- var cookie1 = loginResponse1.Headers.GetValues("Set-Cookie").FirstOrDefault();
- cookie1.Should().NotBeNull();
+ afterLockout.Headers
+ .TryGetValues("Set-Cookie", out var cookies)
+ .Should().BeTrue();
- _client.DefaultRequestHeaders.Add("Cookie", cookie1!);
+ cookies.Should().NotBeNullOrEmpty();
+ }
+
+ [Fact]
+ public async Task TryLogin_WithValidCredentials_ShouldReturnSuccessfulPreview()
+ {
+ var user = await _factory.CreateLoginUserAsync();
+
+ using var client = CreateClient(
+ $"try-login-valid-{Guid.NewGuid():N}");
+
+ var response = await client.PostAsJsonAsync(
+ "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/auth/try-login",
+ new
+ {
+ identifier = user.Identifier,
+ secret = user.Secret
+ });
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
- var logoutResponse = await _client.PostAsync("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/auth/logout", null);
- logoutResponse.StatusCode.Should().Be(HttpStatusCode.Found);
-
- var logoutAgain = await _client.PostAsync("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/auth/logout", null);
- logoutAgain.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Found);
+ var result =
+ await response.Content.ReadFromJsonAsync();
- _client.DefaultRequestHeaders.Remove("Cookie");
+ result.Should().NotBeNull();
- var loginResponse2 = await _client.PostAsJsonAsync("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/auth/login", new
+ result!.Success.Should().BeTrue();
+ result.Reason.Should().BeNull();
+ result.PreviewReceipt.Should().NotBeNullOrWhiteSpace();
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task TryLogin_WithValidCredentials_ShouldNotAuthenticateUser()
+ {
+ using var client = CreateClient(
+ "try-login-device-2222222222222222");
+
+ var response = await client.PostAsJsonAsync("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/auth/try-login", new
{
- identifier = "admin",
- secret = "admin"
+ identifier = ValidIdentifier,
+ secret = ValidSecret
});
- loginResponse2.StatusCode.Should().Be(HttpStatusCode.Found);
- var cookie2 = loginResponse2.Headers.GetValues("Set-Cookie").FirstOrDefault();
- cookie2.Should().NotBeNull();
- cookie2.Should().NotBe(cookie1);
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var result = await response.Content.ReadFromJsonAsync();
+
+ result.Should().NotBeNull();
+ result!.Success.Should().BeTrue();
+
+ var meResponse = await client.PostAsJsonAsync(
+ "/auth/me/profile/get",
+ new GetProfileRequest
+ {
+ ProfileKey = null
+ });
+
+ meResponse.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+ }
+
+ [Fact]
+ public async Task TryLogin_WithInvalidCredentials_ShouldReturnFailedPreviewWithoutSession()
+ {
+ var user = await _factory.CreateLoginUserAsync();
+
+ using var client = CreateClient(
+ $"try-invalid-{Guid.NewGuid():N}");
+
+ var response = await client.PostAsJsonAsync(
+ "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/auth/try-login",
+ new
+ {
+ identifier = user.Identifier,
+ secret = "wrong-password"
+ });
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var result =
+ await response.Content.ReadFromJsonAsync();
+
+ result.Should().NotBeNull();
+ result!.Success.Should().BeFalse();
+ result.Reason.Should().Be(AuthFailureReason.InvalidCredentials);
+ result.PreviewReceipt.Should().BeNull();
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
}
[Fact]
- public async Task Authenticated_User_Should_Access_Me_Endpoint()
+ public async Task TryLogin_WithMissingCredentials_ShouldReturnFailedPreview()
{
- var loginResponse = await _client.PostAsJsonAsync("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/auth/login", new
+ using var client = CreateClient(
+ "try-login-device-4444444444444444");
+
+ var response = await client.PostAsJsonAsync("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/auth/try-login", new
{
- identifier = "admin",
- secret = "admin"
+ identifier = "",
+ secret = ""
});
- var cookie = loginResponse.Headers.GetValues("Set-Cookie").First();
- _client.DefaultRequestHeaders.Add("Cookie", cookie);
- var response = await _client.PostAsJsonAsync("/auth/me/profile/get", new GetProfileRequest() { ProfileKey = null });
response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var result = await response.Content.ReadFromJsonAsync();
+
+ result.Should().NotBeNull();
+ result!.Success.Should().BeFalse();
+ result.Reason.Should().Be(AuthFailureReason.InvalidCredentials);
+ result.PreviewReceipt.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task Login_WithValidPreviewReceipt_ShouldAuthenticateUser()
+ {
+ var user = await _factory.CreateLoginUserAsync();
+
+ using var client = CreateClient(
+ $"preview-valid-{Guid.NewGuid():N}");
+
+ var previewResponse = await client.PostAsJsonAsync(
+ "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/auth/try-login",
+ new
+ {
+ identifier = user.Identifier,
+ secret = user.Secret
+ });
+
+ previewResponse.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var preview =
+ await previewResponse.Content.ReadFromJsonAsync();
+
+ preview.Should().NotBeNull();
+ preview!.Success.Should().BeTrue();
+ preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace();
+
+ var loginResponse = await client.PostAsJsonAsync(
+ LoginEndpoint,
+ new
+ {
+ identifier = user.Identifier,
+ secret = user.Secret,
+ previewReceipt = preview.PreviewReceipt
+ });
+
+ loginResponse.StatusCode.Should().Be(HttpStatusCode.Found);
+
+ loginResponse.Headers
+ .TryGetValues("Set-Cookie", out var cookies)
+ .Should().BeTrue();
+
+ cookies.Should().NotBeNullOrEmpty();
+ }
+
+ [Fact]
+ public async Task Login_WithPreviewReceiptFromDifferentDevice_ShouldNotTrustReceipt()
+ {
+ var user = await _factory.CreateLoginUserAsync();
+
+ using var previewClient = CreateClient(
+ $"receipt-owner-{Guid.NewGuid():N}");
+
+ var previewResponse = await previewClient.PostAsJsonAsync(
+ "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/auth/try-login",
+ new
+ {
+ identifier = user.Identifier,
+ secret = user.Secret
+ });
+
+ previewResponse.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var preview = await previewResponse.Content
+ .ReadFromJsonAsync();
+
+ preview.Should().NotBeNull();
+ preview!.Success.Should().BeTrue();
+ preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace();
+
+ // Attempt to use the valid receipt from another device.
+ using var attackerClient = CreateClient(
+ $"receipt-attacker-{Guid.NewGuid():N}");
+
+ var response = await attackerClient.PostAsJsonAsync(
+ "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/auth/login",
+ new
+ {
+ identifier = user.Identifier,
+ secret = "wrong-password",
+ previewReceipt = preview.PreviewReceipt
+ });
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task Login_WithPreviewReceiptAndDifferentSecret_ShouldNotAuthenticate()
+ {
+ var user = await _factory.CreateLoginUserAsync();
+
+ using var client = CreateClient(
+ $"preview-different-secret-{Guid.NewGuid():N}");
+
+ var previewResponse = await client.PostAsJsonAsync(
+ "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/auth/try-login",
+ new
+ {
+ identifier = user.Identifier,
+ secret = user.Secret
+ });
+
+ var preview =
+ await previewResponse.Content.ReadFromJsonAsync();
+
+ preview.Should().NotBeNull();
+ preview!.Success.Should().BeTrue();
+ preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace();
+
+ var loginResponse = await client.PostAsJsonAsync(
+ LoginEndpoint,
+ new
+ {
+ identifier = user.Identifier,
+ secret = "different-secret",
+ previewReceipt = preview.PreviewReceipt
+ });
+
+ loginResponse.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task Login_WithPreviewReceiptAndDifferentIdentifier_ShouldFallBackToNormalAuthentication()
+ {
+ var receiptOwner = await _factory.CreateLoginUserAsync();
+ var otherUser = await _factory.CreateLoginUserAsync();
+
+ using var client = CreateClient(
+ $"preview-different-identifier-{Guid.NewGuid():N}");
+
+ // Create receipt for user A.
+ var previewResponse = await client.PostAsJsonAsync(
+ "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/auth/try-login",
+ new
+ {
+ identifier = receiptOwner.Identifier,
+ secret = receiptOwner.Secret
+ });
+
+ previewResponse.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var preview =
+ await previewResponse.Content.ReadFromJsonAsync();
+
+ preview.Should().NotBeNull();
+ preview!.Success.Should().BeTrue();
+ preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace();
+
+ // Present user A's receipt while authenticating as user B.
+ //
+ // The receipt must NOT be trusted for user B, but it also must not
+ // prevent user B from authenticating with valid credentials.
+ var loginResponse = await client.PostAsJsonAsync(
+ LoginEndpoint,
+ new
+ {
+ identifier = otherUser.Identifier,
+ secret = otherUser.Secret,
+ previewReceipt = preview.PreviewReceipt
+ });
+
+ loginResponse.StatusCode.Should().Be(HttpStatusCode.Found);
+
+ loginResponse.Headers
+ .TryGetValues("Set-Cookie", out var cookies)
+ .Should().BeTrue();
+
+ cookies.Should().NotBeNullOrEmpty();
+
+ GetSessionCookie(loginResponse)
+ .Should().NotBeNullOrWhiteSpace();
+ }
+
+ [Fact]
+ public async Task Login_WithUnknownPreviewReceipt_ShouldFallBackToNormalLoginValidation()
+ {
+ var user = await _factory.CreateLoginUserAsync();
+
+ using var client = CreateClient(
+ $"unknown-receipt-{Guid.NewGuid():N}");
+
+ var response = await client.PostAsJsonAsync(
+ LoginEndpoint,
+ new
+ {
+ identifier = user.Identifier,
+ secret = user.Secret,
+ previewReceipt = $"unknown-{Guid.NewGuid():N}"
+ });
+
+ response.StatusCode.Should().Be(HttpStatusCode.Found);
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out var cookies)
+ .Should().BeTrue();
+
+ cookies.Should().NotBeNullOrEmpty();
+ }
+
+ [Fact]
+ public async Task PreviewReceipt_AfterSuccessfulCommit_ShouldBeConsumed()
+ {
+ var user = await _factory.CreateLoginUserAsync();
+
+ using var client = CreateClient(
+ $"preview-consume-{Guid.NewGuid():N}");
+
+ var previewResponse = await client.PostAsJsonAsync(
+ "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/auth/try-login",
+ new
+ {
+ identifier = user.Identifier,
+ secret = user.Secret
+ });
+
+ var preview =
+ await previewResponse.Content.ReadFromJsonAsync();
+
+ preview.Should().NotBeNull();
+ preview!.Success.Should().BeTrue();
+ preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace();
+
+ var firstCommit = await client.PostAsJsonAsync(
+ LoginEndpoint,
+ new
+ {
+ identifier = user.Identifier,
+ secret = user.Secret,
+ previewReceipt = preview.PreviewReceipt
+ });
+
+ firstCommit.StatusCode.Should().Be(HttpStatusCode.Found);
+
+ firstCommit.Headers
+ .TryGetValues("Set-Cookie", out var cookies)
+ .Should().BeTrue();
+
+ cookies.Should().NotBeNullOrEmpty();
+
+ // Receipt has now been consumed.
+ //
+ // Reusing it must not grant any special trust. The request should
+ // simply fall back to normal credential validation.
+ var secondCommit = await client.PostAsJsonAsync(
+ LoginEndpoint,
+ new
+ {
+ identifier = user.Identifier,
+ secret = "wrong-after-consumption",
+ previewReceipt = preview.PreviewReceipt
+ });
+
+ secondCommit.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task Login_WithRepeatedInvalidCredentials_ShouldLockAccount()
+ {
+ using var client = CreateClient(
+ "lockout-device-111111111111111111");
+
+ var first = await client.PostAsJsonAsync(
+ "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/auth/login",
+ new
+ {
+ identifier = ValidIdentifier,
+ secret = "wrong-password-1"
+ });
+
+ first.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ var second = await client.PostAsJsonAsync(
+ "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/auth/login",
+ new
+ {
+ identifier = ValidIdentifier,
+ secret = "wrong-password-2"
+ });
+
+ second.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ // Correct credentials must not bypass an active lockout.
+ var correctLogin = await client.PostAsJsonAsync(
+ "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/auth/login",
+ new
+ {
+ identifier = ValidIdentifier,
+ secret = ValidSecret
+ });
+
+ correctLogin.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task TryLogin_WithRepeatedInvalidCredentials_ShouldParticipateInLockout()
+ {
+ using var client = CreateClient(
+ "try-lockout-device-222222222222222");
+
+ var first = await client.PostAsJsonAsync(
+ "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/auth/try-login",
+ new
+ {
+ identifier = ValidIdentifier,
+ secret = "wrong-password-1"
+ });
+
+ var firstResult =
+ await first.Content.ReadFromJsonAsync();
+
+ firstResult.Should().NotBeNull();
+ firstResult!.Success.Should().BeFalse();
+
+ var second = await client.PostAsJsonAsync(
+ "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/auth/try-login",
+ new
+ {
+ identifier = ValidIdentifier,
+ secret = "wrong-password-2"
+ });
+
+ var secondResult =
+ await second.Content.ReadFromJsonAsync();
+
+ secondResult.Should().NotBeNull();
+ secondResult!.Success.Should().BeFalse();
+
+ // Account should now be locked.
+ var correctAttempt = await client.PostAsJsonAsync(
+ "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/auth/try-login",
+ new
+ {
+ identifier = ValidIdentifier,
+ secret = ValidSecret
+ });
+
+ var correctResult =
+ await correctAttempt.Content.ReadFromJsonAsync();
+
+ correctResult.Should().NotBeNull();
+ correctResult!.Success.Should().BeFalse();
+ correctResult.Reason.Should().Be(AuthFailureReason.LockedOut);
+ }
+
+ [Fact]
+ public async Task TryLogin_WithValidCredentials_ShouldNotConsumeFailureAttempt()
+ {
+ var user = await _factory.CreateLoginUserAsync();
+
+ using var client = CreateClient(
+ $"try-no-failure-{Guid.NewGuid():N}");
+
+ var preview = await client.PostAsJsonAsync(
+ "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/auth/try-login",
+ new
+ {
+ identifier = user.Identifier,
+ secret = user.Secret
+ });
+
+ var previewResult =
+ await preview.Content.ReadFromJsonAsync();
+
+ previewResult.Should().NotBeNull();
+ previewResult!.Success.Should().BeTrue();
+
+ var failure = await client.PostAsJsonAsync(
+ "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/auth/try-login",
+ new
+ {
+ identifier = user.Identifier,
+ secret = "wrong-password"
+ });
+
+ var failureResult =
+ await failure.Content.ReadFromJsonAsync();
+
+ failureResult.Should().NotBeNull();
+ failureResult!.Success.Should().BeFalse();
+ failureResult.Reason.Should().Be(AuthFailureReason.InvalidCredentials);
+ }
+
+ [Fact]
+ public async Task PreviewReceipt_WithDifferentSecret_ShouldNotSuppressFailureAccounting()
+ {
+ var user = await _factory.CreateLoginUserAsync();
+
+ using var client = CreateClient(
+ $"receipt-accounting-{Guid.NewGuid():N}");
+
+ var previewResponse = await client.PostAsJsonAsync(
+ "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/auth/try-login",
+ new
+ {
+ identifier = user.Identifier,
+ secret = user.Secret
+ });
+
+ previewResponse.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var preview =
+ await previewResponse.Content.ReadFromJsonAsync();
+
+ preview.Should().NotBeNull();
+ preview!.Success.Should().BeTrue();
+ preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace();
+
+ // Receipt was created for the correct secret.
+ // Using it with another secret must NOT suppress failure accounting.
+ var firstFailure = await client.PostAsJsonAsync(
+ "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/auth/login",
+ new
+ {
+ identifier = user.Identifier,
+ secret = "wrong-password-1",
+ previewReceipt = preview.PreviewReceipt
+ });
+
+ firstFailure.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ // MaxFailedAttempts = 2.
+ // This must therefore be failure #2.
+ var secondFailure = await client.PostAsJsonAsync(
+ "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/auth/login",
+ new
+ {
+ identifier = user.Identifier,
+ secret = "wrong-password-2"
+ });
+
+ secondFailure.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ // Account must now be locked. Correct credentials cannot authenticate.
+ var correctLogin = await client.PostAsJsonAsync(
+ "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/auth/login",
+ new
+ {
+ identifier = user.Identifier,
+ secret = user.Secret
+ });
+
+ correctLogin.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
}
[Fact]
- public async Task Anonymous_Should_Not_Access_Me()
+ public async Task PreviewReceipt_FromDifferentDevice_ShouldNotSuppressFailureAccounting()
+ {
+ var user = await _factory.CreateLoginUserAsync();
+
+ using var receiptClient = CreateClient(
+ $"receipt-device-a-{Guid.NewGuid():N}");
+
+ var previewResponse = await receiptClient.PostAsJsonAsync(
+ "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/auth/try-login",
+ new
+ {
+ identifier = user.Identifier,
+ secret = user.Secret
+ });
+
+ var preview =
+ await previewResponse.Content.ReadFromJsonAsync();
+
+ preview.Should().NotBeNull();
+ preview!.Success.Should().BeTrue();
+ preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace();
+
+ using var otherDevice = CreateClient(
+ $"receipt-device-b-{Guid.NewGuid():N}");
+
+ // Different device must not be able to use the receipt
+ // to suppress this failure.
+ var firstFailure = await otherDevice.PostAsJsonAsync(
+ LoginEndpoint,
+ new
+ {
+ identifier = user.Identifier,
+ secret = "wrong-password-1",
+ previewReceipt = preview.PreviewReceipt
+ });
+
+ firstFailure.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ // MaxFailedAttempts = 2. If the previous failure was correctly
+ // accounted for, this second failure must lock the account.
+ var secondFailure = await otherDevice.PostAsJsonAsync(
+ LoginEndpoint,
+ new
+ {
+ identifier = user.Identifier,
+ secret = "wrong-password-2"
+ });
+
+ secondFailure.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ var correctLogin = await otherDevice.PostAsJsonAsync(
+ LoginEndpoint,
+ new
+ {
+ identifier = user.Identifier,
+ secret = user.Secret
+ });
+
+ correctLogin.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task SuccessfulLogin_ShouldResetPreviousFailureAccounting()
+ {
+ var user = await _factory.CreateLoginUserAsync();
+
+ using var client = CreateClient(
+ $"success-reset-{Guid.NewGuid():N}");
+
+ // Failure #1
+ var firstFailure = await client.PostAsJsonAsync(
+ LoginEndpoint,
+ new
+ {
+ identifier = user.Identifier,
+ secret = "wrong-password-1"
+ });
+
+ firstFailure.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ // Successful authentication must reset previous failure accounting.
+ var success = await client.PostAsJsonAsync(
+ LoginEndpoint,
+ new
+ {
+ identifier = user.Identifier,
+ secret = user.Secret
+ });
+
+ success.StatusCode.Should().Be(HttpStatusCode.Found);
+
+ success.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeTrue();
+
+ // If the previous failure was reset, this is failure #1 again,
+ // not failure #2 / lockout.
+ var failureAfterSuccess = await client.PostAsJsonAsync(
+ "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/auth/try-login",
+ new
+ {
+ identifier = user.Identifier,
+ secret = "wrong-password-2"
+ });
+
+ var result =
+ await failureAfterSuccess.Content.ReadFromJsonAsync();
+
+ result.Should().NotBeNull();
+ result!.Success.Should().BeFalse();
+ result.Reason.Should().Be(AuthFailureReason.InvalidCredentials);
+ }
+
+ [Fact]
+ public async Task Lockout_ShouldApplyAcrossDevices()
+ {
+ using var attackerDevice = CreateClient(
+ "lockout-source-device-99999999999999");
+
+ await attackerDevice.PostAsJsonAsync(
+ "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/auth/login",
+ new
+ {
+ identifier = ValidIdentifier,
+ secret = "wrong-password-1"
+ });
+
+ await attackerDevice.PostAsJsonAsync(
+ "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/auth/login",
+ new
+ {
+ identifier = ValidIdentifier,
+ secret = "wrong-password-2"
+ });
+
+ using var differentDevice = CreateClient(
+ "lockout-other-device-000000000000000");
+
+ var response = await differentDevice.PostAsJsonAsync(
+ "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/auth/login",
+ new
+ {
+ identifier = ValidIdentifier,
+ secret = ValidSecret
+ });
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+ }
+
+ #region HTTP Contract & Input Boundary
+
+ [Fact]
+ public async Task Login_WithFormPayload_ShouldAuthenticateUser()
+ {
+ var user = await _factory.CreateLoginUserAsync();
+
+ using var client = CreateClient(
+ $"form-login-{Guid.NewGuid():N}");
+
+ using var content = new FormUrlEncodedContent(
+ new Dictionary
+ {
+ ["Identifier"] = user.Identifier,
+ ["Secret"] = user.Secret
+ });
+
+ var response = await client.PostAsync(
+ LoginEndpoint,
+ content);
+
+ response.StatusCode.Should().Be(HttpStatusCode.Found);
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out var cookies)
+ .Should().BeTrue();
+
+ cookies.Should().NotBeNullOrEmpty();
+ }
+
+ [Fact]
+ public async Task TryLogin_WithFormPayload_ShouldReturnSuccessfulPreview()
+ {
+ var user = await _factory.CreateLoginUserAsync();
+
+ using var client = CreateClient(
+ $"form-try-login-{Guid.NewGuid():N}");
+
+ using var content = new FormUrlEncodedContent(
+ new Dictionary
+ {
+ ["Identifier"] = user.Identifier,
+ ["Secret"] = user.Secret
+ });
+
+ var response = await client.PostAsync(
+ "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/auth/try-login",
+ content);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var result =
+ await response.Content.ReadFromJsonAsync();
+
+ result.Should().NotBeNull();
+ result!.Success.Should().BeTrue();
+ result.PreviewReceipt.Should().NotBeNullOrWhiteSpace();
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+ }
+
+ [Theory]
+ [InlineData("", "admin")]
+ [InlineData(" ", "admin")]
+ [InlineData("admin", "")]
+ [InlineData("admin", " ")]
+ [InlineData("", "")]
+ [InlineData(" ", " ")]
+ public async Task Login_WithMissingOrWhitespaceCredentials_ShouldNotAuthenticate(
+ string identifier,
+ string secret)
+ {
+ using var client = CreateClient(
+ $"invalid-input-device-{Guid.NewGuid():N}");
+
+ var response = await client.PostAsJsonAsync(
+ "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/auth/login",
+ new
+ {
+ identifier,
+ secret
+ });
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+ }
+
+ [Theory]
+ [InlineData("", "admin")]
+ [InlineData(" ", "admin")]
+ [InlineData("admin", "")]
+ [InlineData("admin", " ")]
+ [InlineData("", "")]
+ [InlineData(" ", " ")]
+ public async Task TryLogin_WithMissingOrWhitespaceCredentials_ShouldReturnInvalidCredentials(
+ string identifier,
+ string secret)
+ {
+ using var client = CreateClient(
+ $"invalid-preview-device-{Guid.NewGuid():N}");
+
+ var response = await client.PostAsJsonAsync(
+ "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/auth/try-login",
+ new
+ {
+ identifier,
+ secret
+ });
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var result = await response.Content.ReadFromJsonAsync();
+
+ result.Should().NotBeNull();
+ result!.Success.Should().BeFalse();
+ result.Reason.Should().Be(AuthFailureReason.InvalidCredentials);
+ result.PreviewReceipt.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task TryLogin_WithUnsupportedContentType_ShouldReturnBadRequest()
+ {
+ using var client = CreateClient(
+ "content-type-device-333333333333333");
+
+ using var content = new StringContent(
+ "identifier=admin&secret=admin",
+ System.Text.Encoding.UTF8,
+ "text/plain");
+
+ var response = await client.PostAsync("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/auth/try-login", content);
+
+ response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task Login_WithUnsupportedContentType_ShouldNotAuthenticate()
+ {
+ using var client = CreateClient(
+ "login-content-type-device-444444444");
+
+ using var content = new StringContent(
+ "identifier=admin&secret=admin",
+ System.Text.Encoding.UTF8,
+ "text/plain");
+
+ var response = await client.PostAsync("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/auth/login", content);
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ ((int)response.StatusCode)
+ .Should().BeLessThan(500);
+ }
+
+ [Fact]
+ public async Task TryLogin_WithEmptyJsonObject_ShouldReturnInvalidCredentials()
+ {
+ using var client = CreateClient(
+ "empty-json-device-555555555555555");
+
+ var response = await client.PostAsJsonAsync(
+ "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/auth/try-login",
+ new { });
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var result = await response.Content.ReadFromJsonAsync();
+
+ result.Should().NotBeNull();
+ result!.Success.Should().BeFalse();
+ result.Reason.Should().Be(AuthFailureReason.InvalidCredentials);
+ result.PreviewReceipt.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task Login_WithEmptyJsonObject_ShouldNotAuthenticate()
+ {
+ using var client = CreateClient(
+ "empty-login-json-device-66666666666");
+
+ var response = await client.PostAsJsonAsync(
+ "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/auth/login",
+ new { });
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ ((int)response.StatusCode)
+ .Should().BeLessThan(500);
+ }
+
+ [Fact]
+ public async Task Login_WithJsonPropertyNamesUsingDifferentCasing_ShouldAuthenticate()
+ {
+ var user = await _factory.CreateLoginUserAsync();
+
+ using var client = CreateClient(
+ $"json-casing-{Guid.NewGuid():N}");
+
+ using var content = JsonContent.Create(new Dictionary
+ {
+ ["IDENTIFIER"] = user.Identifier,
+ ["SECRET"] = user.Secret
+ });
+
+ var response = await client.PostAsync(
+ LoginEndpoint,
+ content);
+
+ response.StatusCode.Should().Be(HttpStatusCode.Found);
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out var cookies)
+ .Should().BeTrue();
+
+ cookies.Should().NotBeNullOrEmpty();
+
+ GetSessionCookie(response)
+ .Should().NotBeNullOrWhiteSpace();
+ }
+
+ #endregion
+
+ #region Identifier Enumeration & Failure Disclosure
+
+ [Fact]
+ public async Task Login_WithUnknownIdentifier_ShouldNotRevealWhetherUserExists()
+ {
+ using var unknownUserClient = CreateClient(
+ $"enumeration-unknown-{Guid.NewGuid():N}");
+
+ using var existingUserClient = CreateClient(
+ $"enumeration-existing-{Guid.NewGuid():N}");
+
+ var unknownResponse = await unknownUserClient.PostAsJsonAsync(
+ "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/auth/login",
+ new
+ {
+ identifier = $"unknown-{Guid.NewGuid():N}",
+ secret = "wrong-password"
+ });
+
+ var existingResponse = await existingUserClient.PostAsJsonAsync(
+ "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/auth/login",
+ new
+ {
+ identifier = ValidIdentifier,
+ secret = "wrong-password"
+ });
+
+ unknownResponse.StatusCode.Should().Be(existingResponse.StatusCode);
+
+ unknownResponse.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ existingResponse.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task TryLogin_WithUnknownIdentifier_ShouldReturnSameFailureReasonAsWrongPassword()
+ {
+ var existingUser = await _factory.CreateLoginUserAsync();
+
+ using var unknownClient = CreateClient(
+ $"unknown-{Guid.NewGuid():N}");
+
+ using var existingClient = CreateClient(
+ $"existing-{Guid.NewGuid():N}");
+
+ var unknownResponse = await unknownClient.PostAsJsonAsync(
+ "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/auth/try-login",
+ new
+ {
+ identifier = $"unknown-{Guid.NewGuid():N}",
+ secret = "wrong-password"
+ });
+
+ var existingResponse = await existingClient.PostAsJsonAsync(
+ "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/auth/try-login",
+ new
+ {
+ identifier = existingUser.Identifier,
+ secret = "wrong-password"
+ });
+
+ unknownResponse.StatusCode.Should().Be(HttpStatusCode.OK);
+ existingResponse.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var unknownResult =
+ await unknownResponse.Content.ReadFromJsonAsync();
+
+ var existingResult =
+ await existingResponse.Content.ReadFromJsonAsync();
+
+ unknownResult.Should().NotBeNull();
+ existingResult.Should().NotBeNull();
+
+ unknownResult!.Success.Should().BeFalse();
+ existingResult!.Success.Should().BeFalse();
+
+ unknownResult.Reason
+ .Should().Be(AuthFailureReason.InvalidCredentials);
+
+ existingResult.Reason
+ .Should().Be(AuthFailureReason.InvalidCredentials);
+
+ unknownResult.PreviewReceipt.Should().BeNullOrWhiteSpace();
+ existingResult.PreviewReceipt.Should().BeNullOrWhiteSpace();
+ }
+
+ [Fact]
+ public async Task TryLogin_WithUnknownIdentifier_ShouldNotIssuePreviewReceipt()
+ {
+ using var client = CreateClient(
+ $"unknown-receipt-{Guid.NewGuid():N}");
+
+ var response = await client.PostAsJsonAsync(
+ "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/auth/try-login",
+ new
+ {
+ identifier = $"unknown-{Guid.NewGuid():N}",
+ secret = "some-password"
+ });
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var result =
+ await response.Content.ReadFromJsonAsync();
+
+ result.Should().NotBeNull();
+
+ result!.Success.Should().BeFalse();
+ result.Reason.Should().Be(AuthFailureReason.InvalidCredentials);
+ result.PreviewReceipt.Should().BeNullOrWhiteSpace();
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task TryLogin_WithWrongPassword_ShouldNotIssuePreviewReceipt()
+ {
+ var user = await _factory.CreateLoginUserAsync();
+
+ using var client = CreateClient(
+ $"wrong-password-{Guid.NewGuid():N}");
+
+ var response = await client.PostAsJsonAsync(
+ "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/auth/try-login",
+ new
+ {
+ identifier = user.Identifier,
+ secret = "definitely-wrong-password"
+ });
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var result =
+ await response.Content.ReadFromJsonAsync();
+
+ result.Should().NotBeNull();
+
+ result!.Success.Should().BeFalse();
+ result.Reason.Should().Be(AuthFailureReason.InvalidCredentials);
+ result.PreviewReceipt.Should().BeNullOrWhiteSpace();
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task Login_WithUnknownIdentifier_ShouldNotReturnAuthenticationCredential()
+ {
+ using var client = CreateClient(
+ $"unknown-credential-{Guid.NewGuid():N}");
+
+ var response = await client.PostAsJsonAsync(
+ "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/auth/login",
+ new
+ {
+ identifier = $"unknown-{Guid.NewGuid():N}",
+ secret = ValidSecret
+ });
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ ((int)response.StatusCode)
+ .Should().BeLessThan(500);
+ }
+
+ #endregion
+
+ [Fact]
+ public async Task Login_FailuresOutsideFailureWindow_ShouldNotAccumulateTowardLockout()
+ {
+ _factory.Clock.Reset();
+
+ var user = await _factory.CreateLoginUserAsync();
+
+ using var client = CreateClient(
+ $"failure-window-{Guid.NewGuid():N}");
+
+ // Failure #1
+ var firstFailure = await LoginAsync(
+ client,
+ user.Identifier,
+ "wrong-password-1");
+
+ firstFailure.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ // Default FailureWindow = 15 minutes.
+ // Move beyond the window.
+ _factory.Clock.Advance(
+ TimeSpan.FromMinutes(16));
+
+ // This should begin a new failure window,
+ // rather than becoming failure #2 of the old window.
+ var secondFailure = await LoginAsync(
+ client,
+ user.Identifier,
+ "wrong-password-2");
+
+ secondFailure.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ // If the old failure was incorrectly retained,
+ // MaxFailedAttempts = 2 would have locked the account.
+ var correctLogin = await LoginAsync(
+ client,
+ user.Identifier,
+ user.Secret);
+
+ correctLogin.Headers
+ .TryGetValues("Set-Cookie", out var cookies)
+ .Should().BeTrue();
+
+ cookies.Should().NotBeNullOrEmpty();
+ }
+
+ [Fact]
+ public async Task Login_BeforeLockoutExpires_ShouldRemainLocked()
+ {
+ _factory.Clock.Reset();
+
+ var user = await _factory.CreateLoginUserAsync();
+
+ using var client = CreateClient(
+ $"active-lockout-{Guid.NewGuid():N}");
+
+ await LoginAsync(
+ client,
+ user.Identifier,
+ "wrong-password-1");
+
+ await LoginAsync(
+ client,
+ user.Identifier,
+ "wrong-password-2");
+
+ _factory.Clock.Advance(
+ TimeSpan.FromSeconds(9));
+
+ var response = await LoginAsync(
+ client,
+ user.Identifier,
+ user.Secret);
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task Login_AtLockoutExpirationBoundary_ShouldAllowCorrectCredentials()
+ {
+ _factory.Clock.Reset();
+
+ var user = await _factory.CreateLoginUserAsync();
+
+ using var client = CreateClient(
+ $"lockout-boundary-{Guid.NewGuid():N}");
+
+ await LoginAsync(
+ client,
+ user.Identifier,
+ "wrong-password-1");
+
+ await LoginAsync(
+ client,
+ user.Identifier,
+ "wrong-password-2");
+
+ _factory.Clock.Advance(
+ TimeSpan.FromSeconds(10));
+
+ var response = await LoginAsync(
+ client,
+ user.Identifier,
+ user.Secret);
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out var cookies)
+ .Should().BeTrue();
+
+ cookies.Should().NotBeNullOrEmpty();
+ }
+
+ [Fact]
+ public async Task Login_FailuresWithinFailureWindow_ShouldAccumulateTowardLockout()
+ {
+ _factory.Clock.Reset();
+
+ var user = await _factory.CreateLoginUserAsync();
+
+ using var client = CreateClient(
+ $"failure-window-inside-{Guid.NewGuid():N}");
+
+ var first = await TryLoginAsync(
+ client,
+ user.Identifier,
+ "wrong-password-1");
+
+ first.Success.Should().BeFalse();
+ first.Reason.Should().Be(AuthFailureReason.InvalidCredentials);
+ first.RemainingAttempts.Should().Be(1);
+ first.LockoutUntilUtc.Should().BeNull();
+
+ _factory.Clock.Advance(TimeSpan.FromMinutes(14));
+
+ var second = await TryLoginAsync(
+ client,
+ user.Identifier,
+ "wrong-password-2");
+
+ second.Success.Should().BeFalse();
+ second.Reason.Should().Be(AuthFailureReason.LockedOut);
+ second.RemainingAttempts.Should().Be(0);
+ second.LockoutUntilUtc.Should().NotBeNull();
+ }
+
+ [Fact]
+ public async Task Login_FailureDuringLockout_ShouldNotExtendLockout_WhenExtendLockOnFailureIsDisabled()
+ {
+ _factory.Clock.Reset();
+
+ var user = await _factory.CreateLoginUserAsync();
+
+ using var client = CreateClient(
+ $"lockout-no-extension-{Guid.NewGuid():N}");
+
+ var first = await TryLoginAsync(
+ client,
+ user.Identifier,
+ "wrong-password-1");
+
+ first.Success.Should().BeFalse();
+ first.Reason.Should().Be(AuthFailureReason.InvalidCredentials);
+
+ var second = await TryLoginAsync(
+ client,
+ user.Identifier,
+ "wrong-password-2");
+
+ second.Success.Should().BeFalse();
+ second.Reason.Should().Be(AuthFailureReason.LockedOut);
+ second.LockoutUntilUtc.Should().NotBeNull();
+
+ var originalLockoutUntil = second.LockoutUntilUtc!.Value;
+
+ _factory.Clock.Advance(TimeSpan.FromSeconds(5));
+
+ var duringLockout = await TryLoginAsync(
+ client,
+ user.Identifier,
+ "still-wrong");
+
+ duringLockout.Success.Should().BeFalse();
+ duringLockout.Reason.Should().Be(AuthFailureReason.LockedOut);
+
+ duringLockout.LockoutUntilUtc
+ .Should().Be(originalLockoutUntil);
+
+ _factory.Clock.Set(
+ originalLockoutUntil.AddMilliseconds(1));
+
+ var login = await LoginAsync(
+ client,
+ user.Identifier,
+ user.Secret);
+
+ login.StatusCode.Should().Be(HttpStatusCode.Found);
+
+ login.Headers
+ .TryGetValues("Set-Cookie", out var cookies)
+ .Should().BeTrue();
+
+ cookies.Should().NotBeNullOrEmpty();
+ }
+
+ //[Fact]
+ //public async Task ConcurrentLoginFailures_ShouldNotLoseFailureAttempts()
+ //{
+ // _factory.Clock.Reset();
+
+ // var user = await _factory.CreateLoginUserAsync();
+
+ // using var client1 = CreateClient(
+ // $"concurrent-failure-1-{Guid.NewGuid():N}");
+
+ // using var client2 = CreateClient(
+ // $"concurrent-failure-2-{Guid.NewGuid():N}");
+
+ // var task1 = TryLoginAsync(
+ // client1,
+ // user.Identifier,
+ // "wrong-password-1");
+
+ // var task2 = TryLoginAsync(
+ // client2,
+ // user.Identifier,
+ // "wrong-password-2");
+
+ // var results = await Task.WhenAll(task1, task2);
+
+ // results.Should().OnlyContain(x => !x.Success);
+
+ // using var verificationClient = CreateClient(
+ // $"concurrent-failure-verification-{Guid.NewGuid():N}");
+
+ // var verification = await TryLoginAsync(
+ // verificationClient,
+ // user.Identifier,
+ // user.Secret);
+
+ // verification.Success.Should().BeFalse();
+ // verification.Reason.Should().Be(AuthFailureReason.LockedOut);
+ // verification.RemainingAttempts.Should().Be(0);
+ // verification.LockoutUntilUtc.Should().NotBeNull();
+ //}
+
+ //[Fact]
+ //public async Task ConcurrentSuccessfulLogins_FromDifferentDevices_ShouldCreateUsableSessions()
+ //{
+ // _factory.Clock.Reset();
+
+ // var user = await _factory.CreateLoginUserAsync();
+
+ // var device1 = $"concurrent-success-1-{Guid.NewGuid():N}";
+ // var device2 = $"concurrent-success-2-{Guid.NewGuid():N}";
+
+ // using var client1 = CreateClient(device1);
+ // using var client2 = CreateClient(device2);
+
+ // var responses = await Task.WhenAll(
+ // LoginAsync(client1, user.Identifier, user.Secret),
+ // LoginAsync(client2, user.Identifier, user.Secret));
+
+ // responses.Should().OnlyContain(
+ // x => x.StatusCode == HttpStatusCode.Found);
+
+ // var cookie1 = responses[0]
+ // .Headers
+ // .GetValues("Set-Cookie")
+ // .First();
+
+ // var cookie2 = responses[1]
+ // .Headers
+ // .GetValues("Set-Cookie")
+ // .First();
+
+ // cookie1.Should().NotBeNullOrWhiteSpace();
+ // cookie2.Should().NotBeNullOrWhiteSpace();
+ // cookie1.Should().NotBe(cookie2);
+
+ // using var authenticatedClient1 = CreateClient(device1);
+ // using var authenticatedClient2 = CreateClient(device2);
+
+ // authenticatedClient1.DefaultRequestHeaders.Add(
+ // "Cookie",
+ // cookie1);
+
+ // authenticatedClient2.DefaultRequestHeaders.Add(
+ // "Cookie",
+ // cookie2);
+
+ // var meResponses = await Task.WhenAll(
+ // authenticatedClient1.PostAsJsonAsync(
+ // "/auth/me/profile/get",
+ // new GetProfileRequest { ProfileKey = null }),
+
+ // authenticatedClient2.PostAsJsonAsync(
+ // "/auth/me/profile/get",
+ // new GetProfileRequest { ProfileKey = null }));
+
+ // meResponses.Should().OnlyContain(
+ // x => x.StatusCode == HttpStatusCode.OK);
+ //}
+
+ //[Fact]
+ //public async Task ConcurrentLogin_WithSamePreviewReceipt_ShouldNotAllowReceiptToBeConsumedTwice()
+ //{
+ // _factory.Clock.Reset();
+
+ // var user = await _factory.CreateLoginUserAsync();
+
+ // var device = $"preview-concurrent-{Guid.NewGuid():N}";
+
+ // using var previewClient = CreateClient(device);
+
+ // var preview = await TryLoginAsync(
+ // previewClient,
+ // user.Identifier,
+ // user.Secret);
+
+ // preview.Success.Should().BeTrue();
+ // preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace();
+
+ // var receipt = preview.PreviewReceipt!;
+
+ // using var client1 = CreateClient(device);
+ // using var client2 = CreateClient(device);
+
+ // var responses = await Task.WhenAll(
+ // LoginWithPreviewReceiptAsync(
+ // client1,
+ // user.Identifier,
+ // user.Secret,
+ // receipt),
+
+ // LoginWithPreviewReceiptAsync(
+ // client2,
+ // user.Identifier,
+ // user.Secret,
+ // receipt));
+
+ // responses.Should().OnlyContain(
+ // x => x.StatusCode == HttpStatusCode.Found);
+
+ // using var replayClient = CreateClient(device);
+
+ // var replay = await LoginWithPreviewReceiptAsync(
+ // replayClient,
+ // user.Identifier,
+ // "wrong-password",
+ // receipt);
+
+ // replay.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+
+ // var failureState = await TryLoginAsync(
+ // replayClient,
+ // user.Identifier,
+ // "another-wrong-password");
+
+ // failureState.Success.Should().BeFalse();
+ // failureState.Reason.Should().Be(AuthFailureReason.LockedOut);
+ //}
+
+
+ private HttpClient CreateClient(
+ string deviceId = "test-device-1234567890123456")
+ {
+ var client = _factory.CreateClient(
+ new WebApplicationFactoryClientOptions
+ {
+ AllowAutoRedirect = false,
+ HandleCookies = false
+ });
+
+ client.DefaultRequestHeaders.Add(
+ "Origin",
+ "https://localhost:6130");
+
+ client.DefaultRequestHeaders.Add(
+ "X-UDID",
+ deviceId);
+
+ return client;
+ }
+
+ private static Task LoginAsync(HttpClient client, string identifier, string secret)
+ {
+ return client.PostAsJsonAsync(
+ LoginEndpoint,
+ new
+ {
+ identifier,
+ secret
+ });
+ }
+
+ private static async Task TryLoginAsync(HttpClient client, string identifier, string secret)
+ {
+ var response = await client.PostAsJsonAsync(
+ "auth/try-login",
+ new LoginRequest
+ {
+ Identifier = identifier,
+ Secret = secret
+ });
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var result = await response.Content.ReadFromJsonAsync();
+
+ result.Should().NotBeNull();
+
+ return result!;
+ }
+
+ private static Task LoginWithPreviewReceiptAsync(HttpClient client, string identifier, string secret, string previewReceipt)
+ {
+ return client.PostAsJsonAsync("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/auth/login", new
+ {
+ identifier,
+ secret,
+ previewReceipt
+ });
+ }
+
+ private static string GetSessionCookie(
+ HttpResponseMessage response)
+ {
+ response.Headers
+ .TryGetValues("Set-Cookie", out var cookies)
+ .Should().BeTrue();
+
+ cookies.Should().NotBeNullOrEmpty();
+
+ return cookies!.First();
+ }
+
+ private void AdvanceClock(TimeSpan duration)
+ {
+ _factory.Clock.Advance(duration);
+ }
+
+ private DateTimeOffset GetUtcNow()
{
- var response = await _client.PostAsync("/auth/me/profile/get", null);
- response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+ return _factory.Clock.UtcNow;
}
-}
\ No newline at end of file
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/AuthenticationSecurityStateStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/AuthenticationSecurityStateStoreContractTests.cs
index e3fc6db3..0f05a231 100644
--- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/AuthenticationSecurityStateStoreContractTests.cs
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/AuthenticationSecurityStateStoreContractTests.cs
@@ -231,6 +231,7 @@ public async Task UpdateAsync_WhenExpectedVersionMatches_PersistsChanges()
var updated = original.RegisterFailure(
Now,
threshold: 3,
+ failureWindow: TimeSpan.FromMinutes(5),
lockoutDuration: TimeSpan.FromMinutes(15));
await store.UpdateAsync(
@@ -266,6 +267,7 @@ public async Task UpdateAsync_WhenExpectedVersionIsStale_ThrowsConflict()
var updated = original.RegisterFailure(
Now,
threshold: 3,
+ failureWindow: TimeSpan.FromMinutes(5),
lockoutDuration: TimeSpan.FromMinutes(15));
var act = () => store.UpdateAsync(
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreAuthenticationStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreAuthenticationStoreTests.cs
index 79de0b8f..6239983e 100644
--- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreAuthenticationStoreTests.cs
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreAuthenticationStoreTests.cs
@@ -62,6 +62,7 @@ public async Task Update_With_RegisterFailure_Should_Increment_Version()
var updated = existing!.RegisterFailure(
DateTimeOffset.UtcNow,
threshold: 3,
+ failureWindow: TimeSpan.FromMinutes(5),
lockoutDuration: TimeSpan.FromMinutes(5));
await store.UpdateAsync(updated, expectedVersion: 0);
@@ -89,7 +90,7 @@ public async Task Update_With_Wrong_Version_Should_Throw()
var userKey = UserKey.FromGuid(Guid.NewGuid());
var state = AuthenticationSecurityState.CreateAccount(tenant, userKey);
await store.AddAsync(state);
- var updated = state.RegisterFailure(DateTimeOffset.UtcNow, 3, TimeSpan.FromMinutes(5));
+ var updated = state.RegisterFailure(DateTimeOffset.UtcNow, 3, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5));
await Assert.ThrowsAsync(() => store.UpdateAsync(updated, expectedVersion: 999));
}
@@ -103,7 +104,7 @@ public async Task RegisterSuccess_Should_Clear_Failures()
var userKey = UserKey.FromGuid(Guid.NewGuid());
var state = AuthenticationSecurityState.CreateAccount(tenant, userKey)
- .RegisterFailure(DateTimeOffset.UtcNow, 3, TimeSpan.FromMinutes(5));
+ .RegisterFailure(DateTimeOffset.UtcNow, 3, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5));
await using (var db1 = CreateDb(connection))
{