mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 10:21:10 +00:00
51a293dbcc
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>
157 lines
6.4 KiB
C#
157 lines
6.4 KiB
C#
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");
|
|
}
|
|
}
|