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);
}
}