mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 08:41: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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user