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);
var change = new
{
currentpassword = SessionTestHost.Password,
newpassword = NewPassword,
confirmpassword = NewPassword
};
using (var changed = await host.SendAsync(HttpMethod.Post, "api/Authentication/ChangePassword", before, change))
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);
}
[Fact]
public async Task A_member_demoted_on_the_team_page_is_refused_and_signs_in_again_with_the_new_role()
{
await using var host = await SessionTestHost.StartAsync();
await host.AddUserAsync("admin@example.com", "Admin");
var member = await host.AddUserAsync("sam@example.com", "Admin");
await host.EnsureRoleAsync("Scheduler");
var admin = await host.SignInAsync("admin@example.com");
var before = await host.SignInAsync("sam@example.com");
Assert.Equal(new[] { "Admin" }, RolesIn(before));
await AssertAcceptedAsync(host, before);
await UpdateMemberAsync(host, admin, member.Id, isActive: true, role: "Scheduler");
await AssertRefusedAsync(host, before);
var after = await host.SignInAsync("sam@example.com");
Assert.Equal(new[] { "Scheduler" }, RolesIn(after));
await AssertAcceptedAsync(host, after);
}
[Fact]
public async Task A_user_whose_role_an_admin_edits_is_refused_and_signs_in_again_with_the_new_role()
{
await using var host = await SessionTestHost.StartAsync();
await host.AddUserAsync("admin@example.com", "Admin");
var member = await host.AddUserAsync("sam@example.com", "Admin");
await host.EnsureRoleAsync("Dispatcher");
var admin = await host.SignInAsync("admin@example.com");
var before = await host.SignInAsync("sam@example.com");
await AssertAcceptedAsync(host, before);
var edit = new
{
id = member.Id,
name = "Sam",
email = "sam@example.com",
role = "Dispatcher"
};
using (var edited = await host.SendAsync(HttpMethod.Put, "api/User/EditUser", admin, edit))
Assert.Equal(HttpStatusCode.OK, edited.StatusCode);
await AssertRefusedAsync(host, before);
var after = await host.SignInAsync("sam@example.com");
Assert.Equal(new[] { "Dispatcher" }, RolesIn(after));
await AssertAcceptedAsync(host, after);
}
[Fact]
public async Task Editing_a_member_without_changing_role_status_or_email_keeps_their_session()
{
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 UpdateMemberAsync(host, admin, member.Id, isActive: true);
await AssertAcceptedAsync(host, before);
}
private static string[] RolesIn(string token) =>
new JwtSecurityTokenHandler().ReadJwtToken(token).Claims
.Where(claim => claim.Type == ClaimTypes.Role)
.Select(claim => claim.Value)
.ToArray();
private static async Task UpdateMemberAsync(SessionTestHost host, string adminToken, string userId, bool isActive, string role = "Scheduler")
{
using var response = await host.SendAsync(HttpMethod.Put, $"api/team-members/{userId}", adminToken, new
{
name = "Sam Lee",
role,
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);
}
}