mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 12:11:14 +00:00
f979e18811
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>
190 lines
8.4 KiB
C#
190 lines
8.4 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using SatelliteProvider.TestSupport;
|
|
|
|
namespace SatelliteProvider.IntegrationTests;
|
|
|
|
public static class JwtIntegrationTests
|
|
{
|
|
private const string ProtectedTilesPath = "/api/satellite/tiles/latlon?Latitude=47.461747&Longitude=37.647063&ZoomLevel=18";
|
|
private const string ProtectedRegionPath = "/api/satellite/region/00000000-0000-0000-0000-000000000000";
|
|
|
|
public static async Task RunAll(string apiUrl, string secret)
|
|
{
|
|
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");
|
|
}
|
|
|
|
private static async Task AnonymousRequest_To_AnyEndpoint_Returns401(string apiUrl)
|
|
{
|
|
Console.WriteLine();
|
|
Console.WriteLine("AZ-487 AC-1: Anonymous request to a protected endpoint returns 401");
|
|
|
|
using var anon = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(1) };
|
|
var response = await anon.GetAsync(ProtectedTilesPath);
|
|
var status = (int)response.StatusCode;
|
|
|
|
if (status != 401)
|
|
{
|
|
throw new Exception($"Expected 401 for anonymous request, got {status}");
|
|
}
|
|
|
|
Console.WriteLine($" ✓ Anonymous request rejected with HTTP {status}");
|
|
}
|
|
|
|
private static async Task ExpiredToken_Returns401(string apiUrl, string secret)
|
|
{
|
|
Console.WriteLine();
|
|
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 = JwtTestHelpers.MintExpired(secret);
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", expired);
|
|
|
|
var response = await client.GetAsync(ProtectedTilesPath);
|
|
var status = (int)response.StatusCode;
|
|
|
|
if (status != 401)
|
|
{
|
|
throw new Exception($"Expected 401 for expired token, got {status}");
|
|
}
|
|
|
|
Console.WriteLine($" ✓ Expired token rejected with HTTP {status}");
|
|
}
|
|
|
|
private static async Task InvalidSignature_Returns401(string apiUrl, string secret)
|
|
{
|
|
Console.WriteLine();
|
|
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 = JwtTestHelpers.MintAuthenticated(secret);
|
|
var tampered = JwtTokenFactory.TamperSignature(valid);
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tampered);
|
|
|
|
var response = await client.GetAsync(ProtectedRegionPath);
|
|
var status = (int)response.StatusCode;
|
|
|
|
if (status != 401)
|
|
{
|
|
throw new Exception($"Expected 401 for tampered signature, got {status}");
|
|
}
|
|
|
|
Console.WriteLine($" ✓ Tampered signature rejected with HTTP {status}");
|
|
}
|
|
|
|
private static async Task ValidToken_Returns200_OnHealthyEndpoint(string apiUrl, string secret)
|
|
{
|
|
Console.WriteLine();
|
|
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 = JwtTestHelpers.MintAuthenticated(secret);
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", valid);
|
|
|
|
var response = await client.GetAsync(ProtectedTilesPath);
|
|
var status = (int)response.StatusCode;
|
|
|
|
// The endpoint may legitimately return 200 (tile available) or a tile-download-related
|
|
// error; what we care about for AZ-487 is that the request reached the handler at all
|
|
// (i.e. NOT 401 / 403). Treat 200 as confirmation; treat anything other than 401/403 as
|
|
// "passed auth" — the handler decided the outcome.
|
|
if (status == 401 || status == 403)
|
|
{
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
throw new Exception($"Expected valid-token request to bypass auth, got {status}. Body: {body}");
|
|
}
|
|
|
|
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
|
|
// rendered only when the OpenAPI document declares a `Bearer` security scheme — so the
|
|
// existence of that scheme in the document is the automatable signal for AC-7.
|
|
Console.WriteLine();
|
|
Console.WriteLine("AZ-487 AC-7: Swagger document advertises Bearer security scheme");
|
|
|
|
using var anon = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(1) };
|
|
var response = await anon.GetAsync("/swagger/v1/swagger.json");
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
throw new Exception($"Expected Swagger document to be reachable, got HTTP {(int)response.StatusCode}");
|
|
}
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
using var doc = System.Text.Json.JsonDocument.Parse(body);
|
|
var root = doc.RootElement;
|
|
|
|
if (!root.TryGetProperty("components", out var components) ||
|
|
!components.TryGetProperty("securitySchemes", out var schemes) ||
|
|
!schemes.TryGetProperty("Bearer", out var bearer))
|
|
{
|
|
throw new Exception("Swagger document is missing `components.securitySchemes.Bearer`.");
|
|
}
|
|
|
|
var type = bearer.GetProperty("type").GetString();
|
|
var scheme = bearer.GetProperty("scheme").GetString();
|
|
var format = bearer.TryGetProperty("bearerFormat", out var bf) ? bf.GetString() : null;
|
|
|
|
if (!string.Equals(type, "http", StringComparison.OrdinalIgnoreCase) ||
|
|
!string.Equals(scheme, "bearer", StringComparison.OrdinalIgnoreCase) ||
|
|
!string.Equals(format, "JWT", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
throw new Exception($"Bearer scheme has wrong shape: type={type}, scheme={scheme}, bearerFormat={format}");
|
|
}
|
|
|
|
Console.WriteLine($" ✓ Swagger document declares Bearer (http, bearer, JWT)");
|
|
}
|
|
}
|