using System.IdentityModel.Tokens.Jwt; using System.Net; using System.Net.Http.Json; using Azaion.E2E.Helpers; using FluentAssertions; using Xunit; namespace Azaion.E2E.Tests; /// /// AZ-531 — refresh-token rotation, reuse-detection, sliding/absolute expiry. /// Each test seeds its own user via /users so cleanup never touches the /// shared admin/uploader fixtures. /// public class RefreshTokenFlowTests : IClassFixture { private readonly TestFixture _fixture; public RefreshTokenFlowTests(TestFixture fixture) => _fixture = fixture; private async Task<(string Email, string Password)> SeedUser(string suffix) { var email = $"refresh-{suffix}-{Guid.NewGuid():N}@e2e.local"; var password = "Refresh1234ABC"; // ≥ 12 chars per RegisterUserValidator using var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken); // role=10 = ResourceUploader (numeric per RoleEnum, matches existing test pattern). using var resp = await admin.PostAsync("/users", new { email, password, role = 10 }); resp.StatusCode.Should().Be(HttpStatusCode.OK); return (email, password); } private async Task CleanupUser(string email) { await _fixture.Db.DeleteSessionsFor(email); await _fixture.Db.DeleteAuditEventsFor(email); await _fixture.Db.DeleteUser(email); } [Fact] public async Task AC1_Login_returns_dual_tokens_with_15min_access_and_refresh_session() { // Arrange var (email, password) = await SeedUser("ac1"); try { // Act using var client = _fixture.CreateHttpClient(); var api = new ApiClient(client); var login = await api.LoginFullAsync(email, password); // Assert — access token exp ≈ now + 15 min ±60 s var jwt = new JwtSecurityTokenHandler().ReadJwtToken(login.AccessToken); var driftSeconds = (jwt.ValidTo - DateTime.UtcNow.AddMinutes(15)).TotalSeconds; Math.Abs(driftSeconds).Should().BeLessThan(60, "access token TTL must be 15 min ±60 s"); login.RefreshToken.Length.Should().BeGreaterThanOrEqualTo(43, "AC-1 requires ≥43-char base64url refresh"); var hash = DbHelper.HashRefreshToken(login.RefreshToken); var session = await _fixture.Db.GetSessionByHash(hash); session.Should().NotBeNull("a sessions row must back the issued refresh token"); session!.RevokedAt.Should().BeNull(); } finally { await CleanupUser(email); } } [Fact] public async Task AC2_Refresh_rotates_token_and_chains_parent_session() { // Arrange var (email, password) = await SeedUser("ac2"); try { using var client = _fixture.CreateHttpClient(); var api = new ApiClient(client); var first = await api.LoginFullAsync(email, password); var firstHash = DbHelper.HashRefreshToken(first.RefreshToken); var firstRow = await _fixture.Db.GetSessionByHash(firstHash); // Act — rotate using var refreshResp = await client.PostAsJsonAsync("/token/refresh", new { RefreshToken = first.RefreshToken }); refreshResp.StatusCode.Should().Be(HttpStatusCode.OK); var rotated = await refreshResp.Content.ReadFromJsonAsync(); rotated.Should().NotBeNull(); // Assert — old row revoked=rotated, new row chained var oldRow = await _fixture.Db.GetSessionByHash(firstHash); oldRow!.RevokedAt.Should().NotBeNull(); oldRow.RevokedReason.Should().Be("rotated"); var newHash = DbHelper.HashRefreshToken(rotated!.RefreshToken); var newRow = await _fixture.Db.GetSessionByHash(newHash); newRow.Should().NotBeNull(); newRow!.ParentSessionId.Should().Be(firstRow!.Id); newRow.FamilyId.Should().Be(firstRow.FamilyId, "rotation must stay in the same family"); newRow.RevokedAt.Should().BeNull(); } finally { await CleanupUser(email); } } [Fact] public async Task AC3_Replaying_a_rotated_refresh_kills_the_entire_family() { // Arrange var (email, password) = await SeedUser("ac3"); try { using var client = _fixture.CreateHttpClient(); var api = new ApiClient(client); var first = await api.LoginFullAsync(email, password); using var rotateResp = await client.PostAsJsonAsync("/token/refresh", new { RefreshToken = first.RefreshToken }); rotateResp.StatusCode.Should().Be(HttpStatusCode.OK); var rotated = await rotateResp.Content.ReadFromJsonAsync(); rotated.Should().NotBeNull(); var firstHash = DbHelper.HashRefreshToken(first.RefreshToken); var firstRow = await _fixture.Db.GetSessionByHash(firstHash); // Act — replay R1 (already rotated) using var replayResp = await client.PostAsJsonAsync("/token/refresh", new { RefreshToken = first.RefreshToken }); // Assert replayResp.StatusCode.Should().Be(HttpStatusCode.Unauthorized, "replaying a rotated refresh must fail"); var active = await _fixture.Db.CountActiveInFamily(firstRow!.FamilyId); active.Should().Be(0, "the entire family must be revoked on reuse-detection"); var killed = await _fixture.Db.CountReuseRevokedInFamily(firstRow.FamilyId); killed.Should().BeGreaterThan(0, "at least the rotated child must carry reuse_detected"); // R2 must also be dead using var rUseResp = await client.PostAsJsonAsync("/token/refresh", new { RefreshToken = rotated!.RefreshToken }); rUseResp.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } finally { await CleanupUser(email); } } [Fact] public async Task AC4_Family_older_than_absolute_window_is_rejected() { // Arrange var (email, password) = await SeedUser("ac4"); try { using var client = _fixture.CreateHttpClient(); var api = new ApiClient(client); var first = await api.LoginFullAsync(email, password); var firstHash = DbHelper.HashRefreshToken(first.RefreshToken); var firstRow = await _fixture.Db.GetSessionByHash(firstHash); // Act — backdate the family past the 12 h absolute cap await _fixture.Db.BackdateFamily(firstRow!.FamilyId, TimeSpan.FromHours(13)); using var resp = await client.PostAsJsonAsync("/token/refresh", new { RefreshToken = first.RefreshToken }); // Assert resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized, "absolute expiry must reject regardless of sliding-window state"); } finally { await CleanupUser(email); } } [Fact] public async Task AC5_Refresh_token_is_opaque_and_stored_as_sha256_hash() { // Arrange var (email, password) = await SeedUser("ac5"); try { using var client = _fixture.CreateHttpClient(); var api = new ApiClient(client); var login = await api.LoginFullAsync(email, password); // Assert — token must NOT be a JWT (no header.payload.signature triple // that decodes to JSON). A safe heuristic: refuse anything with two dots // whose first two segments base64url-decode to '{' JSON objects. var dots = login.RefreshToken.Count(c => c == '.'); dots.Should().Be(0, "refresh tokens are opaque base64url, not JWTs"); // Stored as SHA-256 (hex 64 chars). var hash = DbHelper.HashRefreshToken(login.RefreshToken); hash.Length.Should().Be(64); var session = await _fixture.Db.GetSessionByHash(hash); session.Should().NotBeNull("the SHA-256 hash must be the lookup key"); } finally { await CleanupUser(email); } } }