[AZ-487] JWT validation baseline (HS256, all endpoints)
ci/woodpecker/push/01-test Pipeline failed
ci/woodpecker/push/02-build-push unknown status

Adds Microsoft.AspNetCore.Authentication.JwtBearer 8.0.21 and the
SatelliteProvider.Api.Authentication.AddSatelliteJwt extension that
validates HS256 tokens against a shared JWT_SECRET (>=32 bytes, fail
fast at startup). Every minimal-API endpoint now carries
.RequireAuthorization(); the middleware chain is UseExceptionHandler ->
UseHttpsRedirection -> UseCors -> UseAuthentication -> UseAuthorization
-> endpoints. Swagger UI gets a Bearer security definition so the
Authorize button works.

Test infrastructure: JwtTokenFactory (unit) and JwtTestHelpers
(integration) mint deterministic tokens against the same secret; the
integration test runner attaches a default Bearer token to its shared
HttpClient so existing tests continue to exercise protected endpoints.
JwtIntegrationTests adds AC-1..AC-4 and AC-7 (Swagger advertises
Bearer) end-to-end; AuthenticationServiceCollectionExtensionsTests
covers AC-5 (missing/empty/short secret fail-fast) plus env-var
precedence; JwtTokenFactoryTests covers AC-6 (claims pass through
the JwtSecurityTokenHandler.ValidateToken path JwtBearer uses).

docker-compose and scripts/run-tests.sh now propagate JWT_SECRET to
the api and integration-tests containers, with a >=32-byte guard.
.env.example documents the required keys; .env stays gitignored.

Code review verdict: PASS_WITH_WARNINGS (2 Low findings surfaced
in _docs/03_implementation/reviews/batch_01_cycle2_review.md).

Cross-component coordination: gps-denied-onboard and the mission
planner UI must attach Bearer tokens before this lands in dev.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 23:06:23 +03:00
parent 8e15e53782
commit 96cd3c4495
23 changed files with 872 additions and 15 deletions
@@ -0,0 +1,66 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
namespace SatelliteProvider.Api.Authentication;
public static class AuthenticationServiceCollectionExtensions
{
public const string JwtSecretEnvVar = "JWT_SECRET";
public const string JwtSecretConfigKey = "Jwt:Secret";
public const int MinSecretByteLength = 32;
public static IServiceCollection AddSatelliteJwt(this IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
var secret = ResolveSecretOrThrow(configuration);
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = signingKey,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
ValidateIssuer = false,
ValidateAudience = false,
RequireSignedTokens = true,
RequireExpirationTime = true
};
});
return services;
}
internal static string ResolveSecretOrThrow(IConfiguration configuration)
{
var secret = Environment.GetEnvironmentVariable(JwtSecretEnvVar);
if (string.IsNullOrWhiteSpace(secret))
{
secret = configuration[JwtSecretConfigKey];
}
if (string.IsNullOrWhiteSpace(secret))
{
throw new InvalidOperationException(
$"JWT secret is not configured. Set the {JwtSecretEnvVar} environment variable " +
$"or the {JwtSecretConfigKey} configuration key to a value of at least {MinSecretByteLength} bytes.");
}
var byteLength = Encoding.UTF8.GetByteCount(secret);
if (byteLength < MinSecretByteLength)
{
throw new InvalidOperationException(
$"JWT secret is too short ({byteLength} bytes). HMAC-SHA256 requires at least {MinSecretByteLength} bytes " +
$"per RFC 2104 §3. Set {JwtSecretEnvVar} or {JwtSecretConfigKey} to a longer value.");
}
return secret;
}
}
+39
View File
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using SatelliteProvider.Api;
using SatelliteProvider.Api.Authentication;
using SatelliteProvider.Api.DTOs;
using SatelliteProvider.Api.Swagger;
using SatelliteProvider.DataAccess;
@@ -39,6 +40,9 @@ builder.Services.AddTileDownloader();
builder.Services.AddRegionProcessing();
builder.Services.AddRouteManagement();
builder.Services.AddSatelliteJwt(builder.Configuration);
builder.Services.AddAuthorization();
var allowedOrigins = builder.Configuration.GetSection("CorsConfig:AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
var allowAnyOrigin = builder.Configuration.GetValue<bool>("CorsConfig:AllowAnyOrigin");
CorsConfigurationValidator.EnsureSafeForEnvironment(allowedOrigins, allowAnyOrigin, builder.Environment.EnvironmentName);
@@ -70,6 +74,31 @@ builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Satellite Provider API", Version = "v1" });
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "JWT Authorization header using the Bearer scheme. Example: 'Bearer {token}'"
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
Array.Empty<string>()
}
});
c.MapType<UploadImageRequest>(() => new OpenApiSchema
{
Type = "object",
@@ -119,24 +148,31 @@ if (app.Environment.IsDevelopment())
app.UseExceptionHandler();
app.UseHttpsRedirection();
app.UseCors("TilesCors");
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/tiles/{z:int}/{x:int}/{y:int}", ServeTile)
.RequireAuthorization()
.WithOpenApi(op => new(op) { Summary = "Get satellite tile image by z/x/y coordinates (Slippy Map tile server)" });
app.MapGet("/api/satellite/tiles/latlon", GetTileByLatLon)
.RequireAuthorization()
.WithOpenApi(op => new(op) { Summary = "Get satellite tile by latitude and longitude coordinates" });
app.MapGet("/api/satellite/tiles/mgrs", GetSatelliteTilesByMgrs)
.RequireAuthorization()
.ProducesProblem(StatusCodes.Status501NotImplemented)
.WithOpenApi(op => new(op) { Summary = "Get satellite tiles by MGRS coordinates (NOT IMPLEMENTED)" });
app.MapPost("/api/satellite/upload", UploadImage)
.RequireAuthorization()
.Accepts<UploadImageRequest>("multipart/form-data")
.ProducesProblem(StatusCodes.Status501NotImplemented)
.WithOpenApi(op => new(op) { Summary = "Upload image with metadata (NOT IMPLEMENTED)" })
.DisableAntiforgery();
app.MapPost("/api/satellite/request", RequestRegion)
.RequireAuthorization()
.WithOpenApi(op => new(op)
{
Summary = "Request tiles for a region",
@@ -144,9 +180,11 @@ app.MapPost("/api/satellite/request", RequestRegion)
});
app.MapGet("/api/satellite/region/{id:guid}", GetRegionStatus)
.RequireAuthorization()
.WithOpenApi(op => new(op) { Summary = "Get region status and file paths" });
app.MapPost("/api/satellite/route", CreateRoute)
.RequireAuthorization()
.WithOpenApi(op => new(op)
{
Summary = "Create a route with intermediate points",
@@ -154,6 +192,7 @@ app.MapPost("/api/satellite/route", CreateRoute)
});
app.MapGet("/api/satellite/route/{id:guid}", GetRoute)
.RequireAuthorization()
.WithOpenApi(op => new(op) { Summary = "Get route information with calculated points" });
app.Run();
@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.21" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.21"/>
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
@@ -10,6 +10,9 @@
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=satelliteprovider;Username=postgres;Password=postgres"
},
"Jwt": {
"Secret": "DEV-ONLY-DO-NOT-USE-IN-PROD-replace-with-real-secret-via-JWT_SECRET-env-var"
},
"MapConfig": {
"Service": "GoogleMaps",
"ApiKey": ""
+3
View File
@@ -23,6 +23,9 @@
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=satelliteprovider;Username=postgres;Password=postgres"
},
"Jwt": {
"Secret": ""
},
"MapConfig": {
"Service": "GoogleMaps",
"ApiKey": "",