[AZ-494] Enable JWT iss/aud validation with fail-fast startup

Option B per user decision: production ships with empty Jwt.Issuer /
Jwt.Audience in appsettings.json so the API process refuses to start
unless JWT_ISSUER + JWT_AUDIENCE env vars are supplied. Development
ships with grep-friendly DEV-ONLY- placeholders so local + docker
flows keep working unchanged.

AuthenticationServiceCollectionExtensions flips ValidateIssuer +
ValidateAudience to true and wires ValidIssuer / ValidAudience via a
new ResolveRequiredOrThrow helper that all three required values
(secret, iss, aud) now share. JwtTokenFactory.Create + CreateExpired
gain optional iss / aud parameters (default null) so existing call
sites compile unchanged. JwtTestHelpers adds MintAuthenticated /
MintExpired wrappers that resolve iss + aud from env, plus
ResolveIssuerOrThrow / ResolveAudienceOrThrow. PerfBootstrap.MintToken
+ Program.cs JWT bootstrap migrated to the new surface so the perf
harness and the integration runner both validate against the same
contract.

Adds 4 fail-fast unit tests (missing/empty issuer + audience), 2
negative integration scenarios (WrongIssuer_Returns401,
WrongAudience_Returns401), and re-tags every existing integration
mint site via MintAuthenticated.

Compose, .env.example, run-tests.sh, run-performance-tests.sh all
load + export JWT_ISSUER + JWT_AUDIENCE alongside JWT_SECRET.

Resolves F-AUTH-2 (security_report.md + owasp_review.md). AC-7
(cross-repo suite/_docs/10_auth.md write) deferred — outside this
workspace; tracked in deploy_cycle2.md R3 follow-up.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-12 02:28:48 +03:00
parent 080441db5d
commit f979e18811
27 changed files with 543 additions and 57 deletions
@@ -11,12 +11,14 @@ public static class JwtIntegrationTests
public static async Task RunAll(string apiUrl, string secret)
{
RouteTestHelpers.PrintTestHeader("Test: JWT auth baseline (AZ-487)");
RouteTestHelpers.PrintTestHeader("Test: JWT auth baseline (AZ-487 + AZ-494)");
await AnonymousRequest_To_AnyEndpoint_Returns401(apiUrl);
await ExpiredToken_Returns401(apiUrl, secret);
await InvalidSignature_Returns401(apiUrl, secret);
await ValidToken_Returns200_OnHealthyEndpoint(apiUrl, secret);
await WrongIssuer_Returns401(apiUrl, secret);
await WrongAudience_Returns401(apiUrl, secret);
await SwaggerDocument_AdvertisesBearerSecurityScheme(apiUrl);
Console.WriteLine("✓ JWT integration tests: PASSED");
@@ -45,7 +47,7 @@ public static class JwtIntegrationTests
Console.WriteLine("AZ-487 AC-2: Expired token returns 401");
using var client = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(1) };
var expired = JwtTokenFactory.CreateExpired(secret, JwtTestHelpers.DefaultSubject);
var expired = JwtTestHelpers.MintExpired(secret);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", expired);
var response = await client.GetAsync(ProtectedTilesPath);
@@ -65,7 +67,7 @@ public static class JwtIntegrationTests
Console.WriteLine("AZ-487 AC-3: Tampered signature returns 401");
using var client = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(1) };
var valid = JwtTokenFactory.Create(secret, JwtTestHelpers.DefaultSubject);
var valid = JwtTestHelpers.MintAuthenticated(secret);
var tampered = JwtTokenFactory.TamperSignature(valid);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tampered);
@@ -83,10 +85,10 @@ public static class JwtIntegrationTests
private static async Task ValidToken_Returns200_OnHealthyEndpoint(string apiUrl, string secret)
{
Console.WriteLine();
Console.WriteLine("AZ-487 AC-4: Valid token reaches handler unchanged");
Console.WriteLine("AZ-487 AC-4 / AZ-494 AC-3: Valid token with matching iss/aud reaches handler unchanged");
using var client = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(2) };
var valid = JwtTokenFactory.Create(secret, JwtTestHelpers.DefaultSubject);
var valid = JwtTestHelpers.MintAuthenticated(secret);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", valid);
var response = await client.GetAsync(ProtectedTilesPath);
@@ -105,6 +107,46 @@ public static class JwtIntegrationTests
Console.WriteLine($" ✓ Valid-token request reached handler (HTTP {status})");
}
private static async Task WrongIssuer_Returns401(string apiUrl, string secret)
{
Console.WriteLine();
Console.WriteLine("AZ-494 AC-1: Token with wrong iss returns 401");
using var client = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(1) };
var wrongIssuer = JwtTestHelpers.MintAuthenticated(secret, overrideIssuer: "https://wrong-issuer.invalid/");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", wrongIssuer);
var response = await client.GetAsync(ProtectedTilesPath);
var status = (int)response.StatusCode;
if (status != 401)
{
throw new Exception($"Expected 401 for wrong-issuer token, got {status}");
}
Console.WriteLine($" ✓ Wrong-issuer token rejected with HTTP {status}");
}
private static async Task WrongAudience_Returns401(string apiUrl, string secret)
{
Console.WriteLine();
Console.WriteLine("AZ-494 AC-2: Token with wrong aud returns 401");
using var client = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(1) };
var wrongAudience = JwtTestHelpers.MintAuthenticated(secret, overrideAudience: "wrong-audience-not-satellite");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", wrongAudience);
var response = await client.GetAsync(ProtectedTilesPath);
var status = (int)response.StatusCode;
if (status != 401)
{
throw new Exception($"Expected 401 for wrong-audience token, got {status}");
}
Console.WriteLine($" ✓ Wrong-audience token rejected with HTTP {status}");
}
private static async Task SwaggerDocument_AdvertisesBearerSecurityScheme(string apiUrl)
{
// AC-7: Swagger UI accepts a Bearer token via the "Authorize" button. The button is