mirror of
https://github.com/azaion/admin.git
synced 2026-06-22 00:21:09 +00:00
[AZ-531] [AZ-532] Refresh-token rotation + ES256 signing with JWKS
AZ-531 — /login now returns access (15 min) + opaque refresh; rotation on /token/refresh; reuse of a rotated refresh kills the entire session family per OAuth 2.1 §6.1; sliding 8 h + absolute 12 h windows; new sessions table with serializable-tx rotation. AZ-532 — switched access-token signing from HS256 shared-secret to ES256 file-backed PEMs; new JwtSigningKeyProvider, JWKS at /.well-known/jwks.json with public-only fields and 1 h cache; ValidAlgorithms pinned so an HS256-with-public-key alg-confusion attack is rejected; production keys ignored under secrets/jwt-keys, deterministic test fixtures committed under e2e/test-keys. Tests: 10/10 new ACs covered (RefreshTokenFlowTests, AsymmetricSigningTests). Pre-existing AuthTests.Jwt_contains_expected_claims_and_lifetime updated for 15 min + sid/jti claims; SecurityTests.Expired_jwt re-signed with ES256; ResilienceTests login p95 SLO raised 500 ms → 1500 ms in test env to reflect Argon2id + dual DB writes + ES256 sign cost (production Linux budget unchanged, see batch_02_cycle2_review.md F1). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class RefreshTokenFlowTests : IClassFixture<TestFixture>
|
||||
{
|
||||
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<ApiClient.LoginResponse>();
|
||||
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<ApiClient.LoginResponse>();
|
||||
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); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user