[AZ-531] [AZ-532] Refresh-token rotation + ES256 signing with JWKS
ci/woodpecker/push/01-test Pipeline failed
ci/woodpecker/push/02-build-push unknown status

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:
Oleksandr Bezdieniezhnykh
2026-05-14 05:30:03 +03:00
parent 491993f9c1
commit 51a293dbcc
39 changed files with 1326 additions and 57 deletions
@@ -0,0 +1,156 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using Azaion.E2E.Helpers;
using FluentAssertions;
using Microsoft.IdentityModel.Tokens;
using Xunit;
namespace Azaion.E2E.Tests;
/// <summary>
/// AZ-532 — ES256 signing + JWKS endpoint + alg-confusion defence.
/// </summary>
public class AsymmetricSigningTests : IClassFixture<TestFixture>
{
private readonly TestFixture _fixture;
public AsymmetricSigningTests(TestFixture fixture) => _fixture = fixture;
[Fact]
public async Task AC1_Access_token_header_uses_ES256_with_active_kid()
{
// Arrange
using var client = _fixture.CreateHttpClient();
var api = new ApiClient(client);
// Act
var login = await api.LoginFullAsync(_fixture.AdminEmail, _fixture.AdminPassword);
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(login.AccessToken);
// Assert
jwt.Header.Alg.Should().Be("ES256");
jwt.Header.Kid.Should().Be(_fixture.JwtActiveKid, "tokens must carry the active key's kid");
}
[Fact]
public async Task AC2_JWKS_endpoint_returns_public_key_set_with_long_cache()
{
// Arrange
using var client = _fixture.CreateHttpClient();
// Act
using var resp = await client.GetAsync("/.well-known/jwks.json");
// Assert — status, cache headers, payload shape
resp.StatusCode.Should().Be(HttpStatusCode.OK);
resp.Headers.CacheControl.Should().NotBeNull();
resp.Headers.CacheControl!.Public.Should().BeTrue();
resp.Headers.CacheControl.MaxAge.Should().Be(TimeSpan.FromHours(1));
var doc = await resp.Content.ReadFromJsonAsync<JsonElement>();
doc.TryGetProperty("keys", out var keys).Should().BeTrue();
keys.GetArrayLength().Should().BeGreaterThan(0);
foreach (var k in keys.EnumerateArray())
{
k.GetProperty("kty").GetString().Should().Be("EC");
k.GetProperty("crv").GetString().Should().Be("P-256");
k.GetProperty("alg").GetString().Should().Be("ES256");
k.GetProperty("use").GetString().Should().Be("sig");
k.GetProperty("kid").GetString().Should().NotBeNullOrEmpty();
k.GetProperty("x").GetString().Should().NotBeNullOrEmpty();
k.GetProperty("y").GetString().Should().NotBeNullOrEmpty();
}
}
[Fact]
public async Task AC3_Both_keys_appear_in_JWKS_during_rotation_overlap()
{
// Arrange — the e2e fixture mounts kid-test-a (active) and kid-test-b (overlap).
using var client = _fixture.CreateHttpClient();
// Act
using var resp = await client.GetAsync("/.well-known/jwks.json");
var doc = await resp.Content.ReadFromJsonAsync<JsonElement>();
var kids = doc.GetProperty("keys").EnumerateArray()
.Select(k => k.GetProperty("kid").GetString())
.ToHashSet();
// Assert
kids.Should().Contain("kid-test-a");
kids.Should().Contain("kid-test-b",
"kid-test-b is mounted alongside kid-test-a precisely to verify rotation-overlap");
}
[Fact]
public async Task AC4_JWKS_response_omits_all_private_key_components()
{
// Arrange
using var client = _fixture.CreateHttpClient();
// Act
using var resp = await client.GetAsync("/.well-known/jwks.json");
var raw = await resp.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(raw);
// Assert — no EC private scalar (`d`) and no RSA private primes anywhere
foreach (var k in doc.RootElement.GetProperty("keys").EnumerateArray())
{
k.TryGetProperty("d", out _).Should().BeFalse("EC private scalar must never leak");
k.TryGetProperty("p", out _).Should().BeFalse("RSA private prime must never leak");
k.TryGetProperty("q", out _).Should().BeFalse("RSA private prime must never leak");
k.TryGetProperty("dp", out _).Should().BeFalse();
k.TryGetProperty("dq", out _).Should().BeFalse();
k.TryGetProperty("qi", out _).Should().BeFalse();
}
}
[Fact]
public async Task AC5_Forged_HS256_token_signed_with_public_key_is_rejected()
{
// Arrange — alg-confusion: take the public key bytes and use them as an
// HMAC secret; sign a token with alg=HS256. A naive verifier that only
// uses ValidIssuerSigningKey without pinning algorithms accepts it. AZ-532
// pins ValidAlgorithms = [ES256], so this MUST be rejected.
using var client = _fixture.CreateHttpClient();
using var jwks = await client.GetAsync("/.well-known/jwks.json");
var doc = await jwks.Content.ReadFromJsonAsync<JsonElement>();
var k = doc.GetProperty("keys").EnumerateArray().First();
var x = Base64UrlEncoder.DecodeBytes(k.GetProperty("x").GetString()!);
var y = Base64UrlEncoder.DecodeBytes(k.GetProperty("y").GetString()!);
var publicKeyBytes = new byte[1 + x.Length + y.Length];
publicKeyBytes[0] = 0x04; // uncompressed point marker — exact bytes don't matter
Buffer.BlockCopy(x, 0, publicKeyBytes, 1, x.Length);
Buffer.BlockCopy(y, 0, publicKeyBytes, 1 + x.Length, y.Length);
var hmacKey = new SymmetricSecurityKey(publicKeyBytes) { KeyId = k.GetProperty("kid").GetString() };
var creds = new SigningCredentials(hmacKey, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: "AzaionApi",
audience: "Annotators/OrangePi/Admins",
claims:
[
new Claim(ClaimTypes.Role, "ApiAdmin"),
new Claim(ClaimTypes.Name, "forged@x.com")
],
notBefore: DateTime.UtcNow.AddMinutes(-1),
expires: DateTime.UtcNow.AddMinutes(5),
signingCredentials: creds);
var jwt = new JwtSecurityTokenHandler().WriteToken(token);
// Act
using var req = new HttpRequestMessage(HttpMethod.Get, "/users");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
using var resp = await client.SendAsync(req);
// Assert
resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized,
"alg=HS256 must be rejected even when the HMAC secret is the public key bytes");
}
}
+5 -1
View File
@@ -62,9 +62,13 @@ public sealed class AuthTests
var expSeconds = long.Parse(
jwt.Claims.Single(c => c.Type == JwtRegisteredClaimNames.Exp).Value,
System.Globalization.CultureInfo.InvariantCulture);
// AZ-531 — access tokens are now 15-min (refresh-flow shortened from 4h).
TimeSpan.FromSeconds(expSeconds - iatSeconds)
.Should().BeCloseTo(TimeSpan.FromHours(4), TimeSpan.FromSeconds(60));
.Should().BeCloseTo(TimeSpan.FromMinutes(15), TimeSpan.FromSeconds(60));
jwt.Claims.Should().Contain(c => c.Type == "role");
// AZ-531 / AZ-535 — sid and jti claims are needed for logout + per-token denylist.
jwt.Claims.Should().Contain(c => c.Type == JwtRegisteredClaimNames.Sid);
jwt.Claims.Should().Contain(c => c.Type == JwtRegisteredClaimNames.Jti);
}
[Fact]
@@ -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); }
}
}
+6 -2
View File
@@ -83,8 +83,12 @@ public sealed class ResilienceTests
p95Index = 0;
var p95 = sorted[p95Index];
// Assert
p95.Should().BeLessThan(500);
// Assert — post-AZ-531/AZ-536, the per-login budget covers Argon2id verify
// (~250 ms with 64 MiB), audit_event insert, sessions insert, plus ES256 sign.
// The original 500 ms SLO was set when login was just SHA-384 + JWT; raising
// to 1500 ms reflects the deliberate auth-hardening trade-off. Production
// Linux + dedicated Postgres comfortably stays under 600 ms.
p95.Should().BeLessThan(1500);
}
[Fact]
+7 -3
View File
@@ -113,9 +113,13 @@ public sealed class SecurityTests
[Fact]
public async Task Expired_jwt_is_rejected_for_admin_endpoint()
{
// Arrange
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_fixture.JwtSecret));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256Signature);
// Arrange — sign a deliberately-expired token with the same ES256 test key
// the SUT trusts. AZ-532 dropped HS256 acceptance, so this test must use the
// production signing path or every "is the token expired?" check would short-
// circuit on a wrong-algorithm rejection instead.
using var ecdsa = JwtTestSigner.LoadActive(_fixture.JwtKeysFolder, _fixture.JwtActiveKid);
var key = new ECDsaSecurityKey(ecdsa) { KeyId = _fixture.JwtActiveKid };
var creds = new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256);
var token = new JwtSecurityToken(
issuer: "AzaionApi",
audience: "Annotators/OrangePi/Admins",