mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 10:31:14 +00:00
[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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user