mirror of
https://github.com/azaion/admin.git
synced 2026-06-22 10: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,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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user