[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
+4 -3
View File
@@ -48,8 +48,9 @@ public class BusinessExceptionHandler(ILogger<BusinessExceptionHandler> logger)
private static int MapStatusCode(ExceptionEnum kind) => kind switch
{
ExceptionEnum.AccountLocked => StatusCodes.Status423Locked,
ExceptionEnum.LoginRateLimited => StatusCodes.Status429TooManyRequests,
_ => StatusCodes.Status409Conflict
ExceptionEnum.AccountLocked => StatusCodes.Status423Locked,
ExceptionEnum.LoginRateLimited => StatusCodes.Status429TooManyRequests,
ExceptionEnum.InvalidRefreshToken => StatusCodes.Status401Unauthorized,
_ => StatusCodes.Status409Conflict
};
}
+98 -13
View File
@@ -1,4 +1,3 @@
using System.Text;
using System.Threading.RateLimiting;
using Azaion.Common;
using Azaion.Common.Configs;
@@ -14,6 +13,8 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.Rewrite;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
using Serilog;
@@ -33,9 +34,15 @@ builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(o =>
o.MultipartBodyLengthLimit = 209715200);
var jwtConfig = builder.Configuration.GetSection(nameof(JwtConfig)).Get<JwtConfig>();
if (jwtConfig == null || string.IsNullOrEmpty(jwtConfig.Secret))
throw new Exception("Missing configuration section: JwtConfig");
var signingKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(jwtConfig.Secret));
if (jwtConfig == null || string.IsNullOrEmpty(jwtConfig.Issuer) || string.IsNullOrEmpty(jwtConfig.Audience))
throw new Exception("Missing configuration section: JwtConfig (Issuer + Audience required)");
// AZ-532 — load ES256 signing keys eagerly so JwtBearer can resolve issuer signing
// keys via the same provider DI registers below for AuthService.
var signingKeyLoggerFactory = LoggerFactory.Create(c => c.AddSerilog(Log.Logger));
var jwtSigningKeyProvider = new JwtSigningKeyProvider(
Options.Create(jwtConfig),
signingKeyLoggerFactory.CreateLogger<JwtSigningKeyProvider>());
// Fail-fast for DB connection strings — surfaces a missing env var at startup
// instead of on the first request to a DB-backed endpoint.
@@ -54,13 +61,22 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
{
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtConfig.Issuer,
ValidAudience = jwtConfig.Audience,
IssuerSigningKey = signingKey
ValidIssuer = jwtConfig.Issuer,
ValidAudience = jwtConfig.Audience,
// AZ-532 AC-5 — pin algorithms so a token forged with alg=HS256 using the
// public key as the HMAC secret cannot pass validation.
ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256],
IssuerSigningKeyResolver = (_, _, kid, _) =>
{
if (string.IsNullOrEmpty(kid))
return jwtSigningKeyProvider.All.Select(k => (SecurityKey)k.SecurityKey);
var hit = jwtSigningKeyProvider.All.FirstOrDefault(k => k.Kid == kid);
return hit != null ? [hit.SecurityKey] : [];
}
};
});
@@ -105,11 +121,16 @@ builder.Services.Configure<ResourcesConfig>(builder.Configuration.GetSection(nam
builder.Services.Configure<JwtConfig>(builder.Configuration.GetSection(nameof(JwtConfig)));
builder.Services.Configure<ConnectionStrings>(builder.Configuration.GetSection(nameof(ConnectionStrings)));
builder.Services.Configure<AuthConfig>(builder.Configuration.GetSection(nameof(AuthConfig)));
builder.Services.Configure<SessionConfig>(builder.Configuration.GetSection(nameof(SessionConfig)));
var authConfig = builder.Configuration.GetSection(nameof(AuthConfig)).Get<AuthConfig>() ?? new AuthConfig();
// AZ-532 — share the eagerly-built provider so JwtBearer and AuthService both
// hold the same set of loaded keys.
builder.Services.AddSingleton<IJwtSigningKeyProvider>(jwtSigningKeyProvider);
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IRefreshTokenService, RefreshTokenService>();
builder.Services.AddScoped<IResourcesService, ResourcesService>();
builder.Services.AddScoped<IDetectionClassService, DetectionClassService>();
builder.Services.AddScoped<IAuditLog, AuditLog>();
@@ -233,13 +254,77 @@ app.MapGet("/health/ready", async (IDbFactory dbFactory, HttpContext http, Cance
#endregion Health endpoints
app.MapPost("/login",
async (LoginRequest request, IUserService userService, IAuthService authService, CancellationToken cancellationToken) =>
async (LoginRequest request,
IUserService userService,
IAuthService authService,
IRefreshTokenService refreshTokens,
CancellationToken cancellationToken) =>
{
var user = await userService.ValidateUser(request, ct: cancellationToken);
return Results.Ok(new { Token = authService.CreateToken(user)});
var (refreshToken, session) = await refreshTokens.IssueForNewLogin(user.Id, cancellationToken);
var access = authService.CreateToken(user, sessionId: session.Id, jti: Guid.NewGuid());
return Results.Ok(new LoginResponse
{
AccessToken = access.Jwt,
AccessExp = access.ExpiresAt,
RefreshToken = refreshToken,
RefreshExp = session.ExpiresAt,
});
})
.RequireRateLimiting(LoginPerIpPolicy)
.WithSummary("Login");
.WithSummary("Login (returns access + refresh token)");
// AZ-531 — refresh-token rotation. Anonymous: clients pass the opaque refresh
// in the request body so an expired access token doesn't block the refresh.
app.MapPost("/token/refresh",
async (RefreshTokenRequest request,
IRefreshTokenService refreshTokens,
IUserService userService,
IAuthService authService,
CancellationToken cancellationToken) =>
{
var (newRefresh, session) = await refreshTokens.Rotate(request.RefreshToken, cancellationToken);
var user = await userService.GetById(session.UserId, cancellationToken);
if (user == null) throw new BusinessException(ExceptionEnum.InvalidRefreshToken);
var access = authService.CreateToken(user, sessionId: session.Id, jti: Guid.NewGuid());
return Results.Ok(new LoginResponse
{
AccessToken = access.Jwt,
AccessExp = access.ExpiresAt,
RefreshToken = newRefresh,
RefreshExp = session.ExpiresAt,
});
})
.AllowAnonymous()
.WithSummary("Rotate a refresh token; returns a fresh access + refresh pair");
// AZ-532 — JWKS endpoint. Verifiers cache for 1 h (Cache-Control: public, max-age=3600).
app.MapGet("/.well-known/jwks.json",
(IJwtSigningKeyProvider keys, HttpContext http) =>
{
http.Response.Headers.CacheControl = "public, max-age=3600";
var jwks = new
{
keys = keys.All.Select(k =>
{
var p = k.Ecdsa.ExportParameters(includePrivateParameters: false);
return new
{
kty = "EC",
crv = "P-256",
kid = k.Kid,
use = "sig",
alg = "ES256",
x = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(p.Q.X!),
y = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(p.Q.Y!)
};
}).ToArray()
};
return Results.Json(jwks);
})
.AllowAnonymous()
.ExcludeFromDescription()
.WithSummary("JWKS — public verification keys");
app.MapPost("/users",
async (RegisterUserRequest registerUserRequest, IValidator<RegisterUserRequest> validator,
+6 -1
View File
@@ -12,7 +12,12 @@
"JwtConfig": {
"Issuer": "AzaionApi",
"Audience": "Annotators/OrangePi/Admins",
"TokenLifetimeHours": 4
"KeysFolder": "secrets/jwt-keys",
"AccessTokenLifetimeMinutes": 15
},
"SessionConfig": {
"RefreshSlidingHours": 8,
"RefreshAbsoluteHours": 12
},
"AuthConfig": {
"RateLimit": {