[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
@@ -1,11 +1,15 @@
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using SatelliteProvider.TestSupport;
namespace SatelliteProvider.IntegrationTests;
public static class JwtTestHelpers
{
public const string JwtSecretEnvVar = "JWT_SECRET";
public const string JwtIssuerEnvVar = "JWT_ISSUER";
public const string JwtAudienceEnvVar = "JWT_AUDIENCE";
public const string DefaultSubject = "integration-tests";
public static string ResolveSecretOrThrow()
@@ -28,8 +32,65 @@ public static class JwtTestHelpers
return secret;
}
// AZ-494: runner-side resolvers for the iss / aud values the API enforces.
// Both MUST be present and match the API container's values, or the API
// will reject every minted token at validation time.
public static string ResolveIssuerOrThrow()
{
var issuer = Environment.GetEnvironmentVariable(JwtIssuerEnvVar);
if (string.IsNullOrWhiteSpace(issuer))
{
throw new InvalidOperationException(
$"{JwtIssuerEnvVar} is not set in the integration test environment. " +
"It must match the JWT_ISSUER configured for the API container (AZ-494).");
}
return issuer;
}
public static string ResolveAudienceOrThrow()
{
var audience = Environment.GetEnvironmentVariable(JwtAudienceEnvVar);
if (string.IsNullOrWhiteSpace(audience))
{
throw new InvalidOperationException(
$"{JwtAudienceEnvVar} is not set in the integration test environment. " +
"It must match the JWT_AUDIENCE configured for the API container (AZ-494).");
}
return audience;
}
public static void AttachDefaultAuthorization(HttpClient httpClient, string token)
{
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
// AZ-494: convenience wrapper. Reads JWT_ISSUER / JWT_AUDIENCE from env
// (cached for the runner's lifetime via resolve calls) and delegates to
// the canonical JwtTokenFactory.Create. Test scenarios that need WRONG
// iss/aud (e.g. negative AC tests) pass overrides explicitly.
public static string MintAuthenticated(
string secret,
string? subject = null,
IEnumerable<Claim>? extraClaims = null,
TimeSpan? lifetime = null,
string? overrideIssuer = null,
string? overrideAudience = null)
{
return JwtTokenFactory.Create(
secret,
subject ?? DefaultSubject,
lifetime,
extraClaims,
issuer: overrideIssuer ?? ResolveIssuerOrThrow(),
audience: overrideAudience ?? ResolveAudienceOrThrow());
}
public static string MintExpired(string secret, string? subject = null)
{
return JwtTokenFactory.CreateExpired(
secret,
subject ?? DefaultSubject,
issuer: ResolveIssuerOrThrow(),
audience: ResolveAudienceOrThrow());
}
}
@@ -21,9 +21,13 @@ internal static class PerfBootstrap
public static int MintToken()
{
string secret;
string issuer;
string audience;
try
{
secret = JwtTestHelpers.ResolveSecretOrThrow();
issuer = JwtTestHelpers.ResolveIssuerOrThrow();
audience = JwtTestHelpers.ResolveAudienceOrThrow();
}
catch (InvalidOperationException ex)
{
@@ -35,7 +39,9 @@ internal static class PerfBootstrap
secret,
PerfSubject,
PerfTokenLifetime,
new[] { new Claim(PermissionsClaimType, GpsPermission) });
new[] { new Claim(PermissionsClaimType, GpsPermission) },
issuer: issuer,
audience: audience);
Console.Out.Write(token);
return 0;
@@ -44,13 +44,17 @@ class Program
?? "Host=postgres;Port=5432;Database=satelliteprovider;Username=postgres;Password=postgres";
string jwtSecret;
string jwtIssuer;
string jwtAudience;
try
{
jwtSecret = JwtTestHelpers.ResolveSecretOrThrow();
jwtIssuer = JwtTestHelpers.ResolveIssuerOrThrow();
jwtAudience = JwtTestHelpers.ResolveAudienceOrThrow();
}
catch (InvalidOperationException ex)
{
Console.WriteLine("❌ Integration tests cannot start without a valid JWT secret.");
Console.WriteLine("❌ Integration tests cannot start: JWT configuration is incomplete.");
Console.WriteLine($" {ex.Message}");
return 1;
}
@@ -60,7 +64,7 @@ class Program
Console.WriteLine($"API URL : {apiUrl}");
Console.WriteLine($"Mode : {(TestRunMode.Smoke ? "smoke (fast subset, tightened timeouts)" : "full")}");
Console.WriteLine($"State : {(keepState ? "keep (DB reset skipped)" : "reset (clean DB at startup, AZ-493)")}");
Console.WriteLine($"Auth : JWT_SECRET resolved ({System.Text.Encoding.UTF8.GetByteCount(jwtSecret)} bytes)");
Console.WriteLine($"Auth : JWT_SECRET resolved ({System.Text.Encoding.UTF8.GetByteCount(jwtSecret)} bytes); iss={jwtIssuer}; aud={jwtAudience}");
Console.WriteLine();
using var httpClient = new HttpClient
@@ -69,7 +73,7 @@ class Program
Timeout = TimeSpan.FromMinutes(15)
};
var defaultToken = JwtTokenFactory.Create(jwtSecret, JwtTestHelpers.DefaultSubject);
var defaultToken = JwtTestHelpers.MintAuthenticated(jwtSecret);
JwtTestHelpers.AttachDefaultAuthorization(httpClient, defaultToken);
try
@@ -4,7 +4,6 @@ using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using Npgsql;
using SatelliteProvider.TestSupport;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.PixelFormats;
@@ -50,7 +49,7 @@ public static class UavUploadTests
}
};
using var client = CreateClient(apiUrl);
AttachToken(client, JwtTokenFactory.Create(secret, JwtTestHelpers.DefaultSubject, extraClaims: GpsClaim()));
AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim()));
// Act
var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
@@ -84,7 +83,7 @@ public static class UavUploadTests
items = coords.Select(c => new { latitude = c.Latitude, longitude = c.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") }).ToArray()
};
using var client = CreateClient(apiUrl);
AttachToken(client, JwtTokenFactory.Create(secret, JwtTestHelpers.DefaultSubject, extraClaims: GpsClaim()));
AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim()));
var good = CreateValidJpeg();
var wrongDimensions = CreateValidJpeg(width: 512, height: 512);
@@ -150,7 +149,7 @@ public static class UavUploadTests
}
};
using var client = CreateClient(apiUrl);
AttachToken(client, JwtTokenFactory.Create(secret, JwtTestHelpers.DefaultSubject, extraClaims: GpsClaim()));
AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim()));
// Act
var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
@@ -174,7 +173,7 @@ public static class UavUploadTests
// Arrange
var coord = NextTestCoordinate();
using var client = CreateClient(apiUrl);
AttachToken(client, JwtTokenFactory.Create(secret, JwtTestHelpers.DefaultSubject, extraClaims: GpsClaim()));
AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim()));
var firstMetadata = new
{
@@ -242,7 +241,7 @@ public static class UavUploadTests
// Arrange
using var client = CreateClient(apiUrl);
AttachToken(client, JwtTokenFactory.Create(secret, JwtTestHelpers.DefaultSubject, extraClaims: new[] { new Claim(PermissionsClaimType, "FL") }));
AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: new[] { new Claim(PermissionsClaimType, "FL") }));
var coord = NextTestCoordinate();
var metadata = new
{
@@ -284,7 +283,7 @@ public static class UavUploadTests
var placeholder = new byte[] { 0xFF, 0xD8, 0xFF, 0xD9 };
var files = Enumerable.Range(0, oversize).Select(_ => placeholder).ToArray();
using var client = CreateClient(apiUrl);
AttachToken(client, JwtTokenFactory.Create(secret, JwtTestHelpers.DefaultSubject, extraClaims: GpsClaim()));
AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim()));
// Act
var response = await PostBatch(client, metadata, files);