using System.Security.Claims; using System.IdentityModel.Tokens.Jwt; using JobTrackerApi.Controllers; using JobTrackerApi.Models; using JobTrackerApi.Services; using JobTrackerApi.Tests.TestSupport; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class AuthSessionRevocationTests { [Fact] public async Task Logout_revokes_the_exact_session_and_a_copied_principal_stops_working() { using var db = TestHostFactory.CreateInMemoryDb(null); db.Users.AddRange( new ApplicationUser { Id = "user-1", UserName = "one@example.test", Email = "one@example.test" }, new ApplicationUser { Id = "user-2", UserName = "two@example.test", Email = "two@example.test" }); db.UserSessions.Add(Session("sid-current", "user-1")); db.UserSessions.Add(Session("sid-other", "user-2")); await db.SaveChangesAsync(); var controller = CreateAuthController(db, TestHostFactory.CreateUserManager()) ; controller.HttpContext.User = Principal("user-1", "sid-current"); Assert.IsType(await controller.Logout(CancellationToken.None)); Assert.False(await LocalSessionValidator.IsValidAsync(db, Principal("user-1", "sid-current"), DateTimeOffset.UtcNow)); Assert.True(await LocalSessionValidator.IsValidAsync(db, Principal("user-2", "sid-other"), DateTimeOffset.UtcNow)); } [Fact] public async Task Logout_best_effort_revokes_an_expired_cookie_without_an_authenticated_principal() { using var db = TestHostFactory.CreateInMemoryDb(null); db.UserSessions.Add(Session("sid-expired-cookie", "user-1")); await db.SaveChangesAsync(); var controller = CreateAuthController(db, TestHostFactory.CreateUserManager()); var expired = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( claims: new[] { new Claim(ClaimTypes.NameIdentifier, "user-1"), new Claim("sid", "sid-expired-cookie"), }, expires: DateTime.UtcNow.AddMinutes(-10))); controller.Request.Headers.Cookie = $"{AuthSessionOptions.SessionCookieName}={expired}"; Assert.IsType(await controller.Logout(CancellationToken.None)); Assert.NotNull((await db.UserSessions.IgnoreQueryFilters().SingleAsync()).RevokedAtUtc); } [Fact] public async Task Password_reset_revokes_all_target_sessions_and_trusted_devices_but_preserves_two_factor() { var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", TwoFactorEnabled = true, TotpSecretEncrypted = "still-present" }; var users = TestHostFactory.CreateUserManager(); users.Setup(x => x.FindByEmailAsync(user.Email)).ReturnsAsync(user); users.Setup(x => x.ResetPasswordAsync(user, "valid-token", "new-password")).ReturnsAsync(Microsoft.AspNetCore.Identity.IdentityResult.Success); using var db = TestHostFactory.CreateInMemoryDb(null); db.UserSessions.AddRange(Session("sid-a", user.Id), Session("sid-b", user.Id), Session("sid-other", "user-2")); db.TrustedDevices.AddRange(Device(user.Id, "hash-a"), Device(user.Id, "hash-b"), Device("user-2", "hash-other")); await db.SaveChangesAsync(); var controller = CreateAuthController(db, users); Assert.IsType(await controller.ResetPassword( new AuthController.ResetPasswordRequest(user.Email, "valid-token", "new-password"), CancellationToken.None)); Assert.All(await db.UserSessions.IgnoreQueryFilters().Where(x => x.UserId == user.Id).ToListAsync(), x => Assert.NotNull(x.RevokedAtUtc)); Assert.Null((await db.UserSessions.IgnoreQueryFilters().SingleAsync(x => x.UserId == "user-2")).RevokedAtUtc); Assert.Empty(await db.TrustedDevices.IgnoreQueryFilters().Where(x => x.UserId == user.Id).ToListAsync()); Assert.Single(await db.TrustedDevices.IgnoreQueryFilters().Where(x => x.UserId == "user-2").ToListAsync()); Assert.True(user.TwoFactorEnabled); Assert.Equal("still-present", user.TotpSecretEncrypted); } [Fact] public async Task Password_change_rotates_the_session_and_keeps_only_the_current_trusted_device() { var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" }; var users = TestHostFactory.CreateUserManager(); users.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); users.Setup(x => x.ChangePasswordAsync(user, "old-password", "new-password")) .ReturnsAsync(Microsoft.AspNetCore.Identity.IdentityResult.Success); var tokens = new Mock(); tokens.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny(), It.IsAny())).ReturnsAsync("new-jwt"); using var db = TestHostFactory.CreateInMemoryDb(null); db.UserSessions.AddRange(Session("sid-current", user.Id), Session("sid-other", user.Id)); const string trustedToken = "current-device-token"; var currentHash = TrustedDeviceService.HashToken(trustedToken); db.TrustedDevices.AddRange(Device(user.Id, currentHash), Device(user.Id, "other-hash"), Device("user-2", "other-user-hash")); await db.SaveChangesAsync(); var controller = CreateAuthController(db, users, tokens.Object); controller.HttpContext.User = Principal(user.Id, "sid-current"); controller.Request.Headers.Cookie = $"{AuthSessionOptions.TrustedDeviceCookieName}={trustedToken}"; Assert.IsType(await controller.ChangePassword( new AuthController.ChangePasswordRequest("old-password", "new-password"), CancellationToken.None)); var sessions = await db.UserSessions.IgnoreQueryFilters().Where(x => x.UserId == user.Id).ToListAsync(); Assert.Equal(2, sessions.Count(x => x.RevokedAtUtc is not null)); Assert.Single(sessions, x => x.RevokedAtUtc is null); Assert.Equal(currentHash, (await db.TrustedDevices.IgnoreQueryFilters().SingleAsync(x => x.UserId == user.Id)).TokenHash); Assert.Single(await db.TrustedDevices.IgnoreQueryFilters().Where(x => x.UserId == "user-2").ToListAsync()); } [Fact] public async Task Session_validator_requires_sid_and_user_to_match_the_same_row() { using var db = TestHostFactory.CreateInMemoryDb(null); db.UserSessions.Add(Session("sid-user-1", "user-1")); await db.SaveChangesAsync(); Assert.False(await LocalSessionValidator.IsValidAsync(db, Principal("user-2", "sid-user-1"), DateTimeOffset.UtcNow)); } [Fact] public async Task Session_validator_rejects_an_unconfirmed_account_when_verification_is_required() { using var db = TestHostFactory.CreateInMemoryDb(null); db.Users.Add(new ApplicationUser { Id = "user-1", Email = "pending@example.com", UserName = "pending@example.com", EmailConfirmed = false }); db.UserSessions.Add(Session("sid-pending", "user-1")); await db.SaveChangesAsync(); Assert.False(await LocalSessionValidator.IsValidAsync(db, Principal("user-1", "sid-pending"), DateTimeOffset.UtcNow, requireConfirmedEmail: true)); Assert.True(await LocalSessionValidator.IsValidAsync(db, Principal("user-1", "sid-pending"), DateTimeOffset.UtcNow)); } [Fact] public async Task Session_validator_rejects_an_account_pending_deletion() { using var db = TestHostFactory.CreateInMemoryDb(null); db.Users.Add(new ApplicationUser { Id = "user-1", Email = "pending-delete@example.test", UserName = "pending-delete@example.test", DeletionStatus = AccountDeletionStatuses.Pending }); db.UserSessions.Add(Session("sid-delete", "user-1")); await db.SaveChangesAsync(); Assert.False(await LocalSessionValidator.IsValidAsync(db, Principal("user-1", "sid-delete"), DateTimeOffset.UtcNow)); } [Fact] public async Task Password_security_stamp_change_invalidates_a_pending_two_factor_challenge() { var user = new ApplicationUser { Id = "user-1", TwoFactorEnabled = true, TotpSecretEncrypted = "not-read-on-stamp-mismatch", SecurityStamp = "new-stamp", }; var users = TestHostFactory.CreateUserManager(user); var pending = new TwoFactorPendingTokenService(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions())); var pendingToken = pending.IssuePendingToken(user.Id, rememberMe: false, securityStamp: "old-stamp"); using var db = TestHostFactory.CreateInMemoryDb(null); var controller = new TwoFactorController( users.Object, Mock.Of(), db, pending, new EphemeralDataProtectionProvider(), BuildConfig()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }, }; Assert.IsType(await controller.Challenge( new TwoFactorController.ChallengeRequest(pendingToken, "123456"), CancellationToken.None)); Assert.Null(pending.Resolve(pendingToken, consume: false)); } private static AuthController CreateAuthController( JobTrackerApi.Data.JobTrackerContext db, Mock> users, ITokenService? tokens = null) => new( BuildConfig(), users.Object, tokens ?? Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), db) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }, }; private static IConfiguration BuildConfig() => new ConfigurationBuilder().AddInMemoryCollection().Build(); private static UserSession Session(string id, string userId) => new() { Id = id, UserId = userId, CreatedAtUtc = DateTimeOffset.UtcNow, LastSeenAtUtc = DateTimeOffset.UtcNow, ExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(1), }; private static TrustedDevice Device(string userId, string hash) => new() { UserId = userId, TokenHash = hash, CreatedAtUtc = DateTimeOffset.UtcNow, LastSeenAtUtc = DateTimeOffset.UtcNow, ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(30), }; private static ClaimsPrincipal Principal(string userId, string sid) => new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId), new Claim("sid", sid), }, "local")); }