using System.IdentityModel.Tokens.Jwt; using System.Net; using System.Security.Claims; namespace SeaHavenIndustries.Tests; /// /// A sign-in token must stop working once the account's password is reset or changed, /// or once the account is deactivated or deleted. Every case goes through the real API /// host: sign in over HTTP, change the account through its endpoint, then call an /// authorized endpoint with the old and the new token. /// public sealed class SessionRevocationTests { private const string SessionStampClaim = "session_stamp"; private const string NewPassword = "Quiet-Tide-77!"; [Fact] public async Task A_token_issued_before_a_password_reset_is_refused_and_the_new_sign_in_works() { await using var host = await SessionTestHost.StartAsync(); await host.AddUserAsync("sam@example.com"); var before = await host.SignInAsync("sam@example.com"); await AssertAcceptedAsync(host, before); await host.ResetPasswordAsync("sam@example.com", NewPassword); await AssertRefusedAsync(host, before); var after = await host.SignInAsync("sam@example.com", NewPassword); await AssertAcceptedAsync(host, after); } [Fact] public async Task A_token_issued_before_a_password_change_is_refused_and_the_new_sign_in_works() { await using var host = await SessionTestHost.StartAsync(); await host.AddUserAsync("sam@example.com"); var before = await host.SignInAsync("sam@example.com"); var other = await host.SignInAsync("sam@example.com"); await AssertAcceptedAsync(host, other); using (var changed = await host.SendAsync(HttpMethod.Post, "api/Authentication/ChangePassword", before, new { currentpassword = SessionTestHost.Password, newpassword = NewPassword, confirmpassword = NewPassword })) Assert.Equal(HttpStatusCode.OK, changed.StatusCode); await AssertRefusedAsync(host, before); await AssertRefusedAsync(host, other); var after = await host.SignInAsync("sam@example.com", NewPassword); await AssertAcceptedAsync(host, after); } [Fact] public async Task A_deactivated_member_is_refused_and_the_old_token_stays_refused_after_reactivation() { await using var host = await SessionTestHost.StartAsync(); await host.AddUserAsync("admin@example.com", "Admin"); var member = await host.AddUserAsync("sam@example.com", "Scheduler"); var admin = await host.SignInAsync("admin@example.com"); var before = await host.SignInAsync("sam@example.com"); await AssertAcceptedAsync(host, before); await UpdateMemberAsync(host, admin, member.Id, isActive: false); await AssertRefusedAsync(host, before); await UpdateMemberAsync(host, admin, member.Id, isActive: true); await AssertRefusedAsync(host, before); var after = await host.SignInAsync("sam@example.com"); await AssertAcceptedAsync(host, after); } [Fact] public async Task A_token_for_an_account_its_owner_deleted_is_refused() { await using var host = await SessionTestHost.StartAsync(); await host.AddUserAsync("sam@example.com"); var before = await host.SignInAsync("sam@example.com"); using (var deleted = await host.SendAsync(HttpMethod.Post, "api/User/DeleteCurrentUser", before)) Assert.Equal(HttpStatusCode.OK, deleted.StatusCode); await AssertRefusedAsync(host, before); } [Fact] public async Task A_token_for_an_account_an_admin_deleted_is_refused() { await using var host = await SessionTestHost.StartAsync(); await host.AddUserAsync("admin@example.com", "Admin"); var member = await host.AddUserAsync("sam@example.com"); var admin = await host.SignInAsync("admin@example.com"); var before = await host.SignInAsync("sam@example.com"); await AssertAcceptedAsync(host, before); using (var deleted = await host.SendAsync(HttpMethod.Delete, "api/User/DeleteUser", admin, new { id = member.Id })) Assert.Equal(HttpStatusCode.OK, deleted.StatusCode); await AssertRefusedAsync(host, before); } [Fact] public async Task A_correctly_signed_token_with_a_forged_session_stamp_is_refused() { await using var host = await SessionTestHost.StartAsync(); await host.AddUserAsync("sam@example.com"); var issued = await host.SignInAsync("sam@example.com"); await AssertAcceptedAsync(host, issued); var forged = SessionTestHost.Resign(issued, claims => { claims.RemoveAll(claim => claim.Type == SessionStampClaim); claims.Add(new Claim(SessionStampClaim, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")); }); var resignedUnchanged = SessionTestHost.Resign(issued, _ => { }); await AssertAcceptedAsync(host, resignedUnchanged); await AssertRefusedAsync(host, forged); } [Fact] public async Task A_correctly_signed_token_without_a_session_stamp_is_refused() { // The shape of every token issued before this check shipped. await using var host = await SessionTestHost.StartAsync(); await host.AddUserAsync("sam@example.com"); var issued = await host.SignInAsync("sam@example.com"); var stampless = SessionTestHost.Resign(issued, claims => claims.RemoveAll(claim => claim.Type == SessionStampClaim)); await AssertRefusedAsync(host, stampless); } [Fact] public async Task A_refused_token_gets_the_same_401_as_no_token_and_nothing_sensitive_is_logged() { await using var host = await SessionTestHost.StartAsync(); var user = await host.AddUserAsync("sam@example.com"); var before = await host.SignInAsync("sam@example.com"); var stampClaim = new JwtSecurityTokenHandler().ReadJwtToken(before).Claims .SingleOrDefault(claim => claim.Type == SessionStampClaim)?.Value; var oldStamp = await host.StoredSecurityStampAsync(user.Id); await host.ResetPasswordAsync("sam@example.com", NewPassword); using var refused = await host.ProfileAsync(before); using var anonymous = await host.ProfileAsync(null); Assert.Equal(HttpStatusCode.Unauthorized, refused.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode); Assert.Equal(await anonymous.Content.ReadAsStringAsync(), await refused.Content.ReadAsStringAsync()); Assert.Empty(await refused.Content.ReadAsStringAsync()); var newStamp = await host.StoredSecurityStampAsync(user.Id); var secrets = new[] { before, oldStamp, newStamp, stampClaim, "sam@example.com" } .Where(secret => !string.IsNullOrEmpty(secret)) .ToList(); var leaks = host.Logged.Entries .Where(entry => secrets.Any(secret => entry.Contains(secret!, StringComparison.OrdinalIgnoreCase))) .ToList(); Assert.Empty(leaks); } private static async Task UpdateMemberAsync(SessionTestHost host, string adminToken, string userId, bool isActive) { using var response = await host.SendAsync(HttpMethod.Put, $"api/team-members/{userId}", adminToken, new { name = "Sam Lee", role = "Scheduler", color = "#0D9488", email = "sam@example.com", phone = "555-0100", serviceAreas = Array.Empty(), isActive }); Assert.True(response.StatusCode == HttpStatusCode.OK, await response.Content.ReadAsStringAsync()); } private static async Task AssertAcceptedAsync(SessionTestHost host, string token) { using var response = await host.ProfileAsync(token); Assert.Equal(HttpStatusCode.OK, response.StatusCode); } private static async Task AssertRefusedAsync(SessionTestHost host, string token) { using var response = await host.ProfileAsync(token); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } }