Files
satellite-provider/_docs/02_tasks/done/AZ-487_jwt_validation_baseline.md
T
Oleksandr Bezdieniezhnykh 96cd3c4495
ci/woodpecker/push/01-test Pipeline failed
ci/woodpecker/push/02-build-push unknown status
[AZ-487] JWT validation baseline (HS256, all endpoints)
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>
2026-05-11 23:06:23 +03:00

17 KiB

JWT validation baseline (HS256, JWT_SECRET, all endpoints)

Task: AZ-487_jwt_validation_baseline Name: JWT validation baseline Description: Add HS256 JWT validation to satellite-provider so every existing endpoint requires a valid token issued by the centralized Admin API; align with the suite-wide auth contract documented in suite/_docs/10_auth.md. Complexity: 2 points Dependencies: None (consumes the suite-level JWT contract suite/_docs/10_auth.md; no in-repo task dependency) Component: WebApi (SatelliteProvider.Api) Tracker: AZ-487 Epic: none (cross-cutting hardening; AZ-488 hard-depends on this)

Problem

Satellite-provider currently has zero authentication — every endpoint is open. The suite-level auth design (suite/_docs/10_auth.md) requires every .NET API instance in the suite to validate JWTs locally using a shared HMAC key supplied via the JWT_SECRET env var. The Security Audit (Step 14, cycle 1, _docs/05_security/owasp_review.md) flagged the absence of authentication as the largest hardening gap blocking public-network exposure. The next planned PBI (AZ-488 — UAV upload endpoint) cannot ship without the auth baseline because UAV uploads must carry an authenticated user identity.

Outcome

  • Every existing HTTP endpoint (except possibly Swagger UI in non-Production) returns 401 Unauthorized when the request has no Authorization: Bearer … header, an expired token, or a token whose HMAC signature does not verify against the JWT_SECRET env var.
  • Authenticated requests reach the existing handlers unchanged — no behavioral change for valid-token callers.
  • A request's user identity is exposed to handlers via the standard HttpContext.User claims principal (sub, email, role, permissions[] per the suite contract).
  • Swagger UI accepts a Bearer token via the standard "Authorize" button so manual testing keeps working.
  • The JWT_SECRET env var is documented in appsettings.Development.json (with a clearly-fake dev value), docker-compose.yml, and (eventually) the deployment scripts.
  • Existing 213 unit + 5 smoke tests continue to pass after test setup is updated to attach a valid dev token.

Scope

Included

  • New ServiceCollection extension SatelliteProvider.Api.Authentication.AuthenticationServiceCollectionExtensions.AddSatelliteJwt(this IServiceCollection, IConfiguration):
    • Adds Microsoft.AspNetCore.Authentication.JwtBearer (use the same minor version as the existing Microsoft.AspNetCore.OpenApi package — currently 8.0.x; if a security upgrade is happening alongside, pin both consistently).
    • Configures AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options => …) with TokenValidationParameters:
      • ValidateIssuerSigningKey = true, IssuerSigningKey = SymmetricSecurityKey(Encoding.UTF8.GetBytes(JWT_SECRET))
      • ValidateLifetime = true, ClockSkew = TimeSpan.FromSeconds(30) (match suite default; document the choice in code)
      • ValidateIssuer = false, ValidateAudience = false (per suite doc — issuer/audience semantics are not specified yet)
      • RequireSignedTokens = true, RequireExpirationTime = true
    • Throws on startup if JWT_SECRET is missing or shorter than 32 bytes (HMAC-SHA256 minimum-secure key size).
  • New ServiceCollection extension call wired into Program.cs: builder.Services.AddSatelliteJwt(builder.Configuration); followed by builder.Services.AddAuthorization(); and the matching app.UseAuthentication(); app.UseAuthorization(); middleware ordering (after UseCors, before endpoint routing).
  • .RequireAuthorization() applied to every existing MapGet/MapPost in Program.cs:
    • GET /tiles/{z}/{x}/{y} (ServeTile)
    • GET /api/satellite/tiles/latlon (GetTileByLatLon)
    • GET /api/satellite/tiles/mgrs (GetSatelliteTilesByMgrs)
    • POST /api/satellite/upload (UploadImage — currently 501 stub)
    • POST /api/satellite/request (RequestRegion)
    • GET /api/satellite/region/{id} (GetRegionStatus)
    • POST /api/satellite/route (CreateRoute)
    • GET /api/satellite/route/{id} (GetRoute)
  • Program.cs Swagger configuration extended with a SecurityDefinition("Bearer", ...) and a global SecurityRequirement so the "Authorize" button appears in Swagger UI.
  • appsettings.json and appsettings.Development.json: add Jwt:Secret configuration key reading JWT_SECRET via the standard ASP.NET Core env-var binding. The dev file ships a 32+ byte placeholder secret clearly marked DEV-ONLY-DO-NOT-USE-IN-PROD.
  • docker-compose.yml: add JWT_SECRET to the api service's environment block, sourcing from the host env (or a .env entry the operator supplies).
  • .env.example (create if missing): include JWT_SECRET= line with comment.
  • Test infrastructure update:
    • Add a small test helper SatelliteProvider.Tests.TestUtilities.JwtTokenFactory (or reuse if a similar one exists) that signs a valid dev token using the same secret used by the integration test environment.
    • Update scripts/run-tests.sh (or the relevant test setup) to ensure the integration test container starts with JWT_SECRET set and the test runner attaches a Bearer token to every request.
    • Update existing smoke tests' HTTP request setup to attach the test token by default.
  • New unit tests:
    • AuthenticationServiceCollectionExtensionsTests: AddSatelliteJwt_RegistersJwtBearerScheme, AddSatelliteJwt_ThrowsOnMissingSecret, AddSatelliteJwt_ThrowsOnShortSecret.
    • Token factory: JwtTokenFactory_ProducesTokenAcceptedByValidationParameters.
  • New integration tests:
    • JwtIntegrationTests.AnonymousRequest_To_AnyEndpoint_Returns401
    • JwtIntegrationTests.ExpiredToken_Returns401
    • JwtIntegrationTests.InvalidSignature_Returns401
    • JwtIntegrationTests.ValidToken_Returns200_OnHealthyEndpoint
  • Documentation:
    • Update _docs/02_document/architecture.md § Architecture Vision to add an "Authentication & authorization" sub-section noting that satellite-provider validates JWTs issued by the centralized Admin API per the suite-level auth contract.
    • Update _docs/02_document/components/01_web_api/description.md to describe the JWT middleware ordering and where .RequireAuthorization() is applied.
    • Update _docs/02_document/modules/api_program.md to reflect the new middleware chain.
    • Reference (do not duplicate) suite/_docs/10_auth.md from this task spec and from the WebApi component description.

Excluded

  • GET /users/me and PUT /users/me endpoints — explicitly not shipped (decision recorded in task discussion: nothing depends on them, the suite-level "hosted on every .NET API instance" policy is over-specified for satellite-provider).
  • Permission-claim enforcement on the existing endpoints — they only require any valid token in this PBI. Per-endpoint permission checks land later (AZ-488 enforces permissions claim contains GPS for the upload endpoint).
  • Refresh/logout endpoints — admin API only, per the suite contract.
  • Issuer/audience claim validation — see Constraints below.
  • Asymmetric (RS256 / ES256) signature support — out of scope; the suite contract specifies HMAC.
  • JWKS endpoint fetching — not needed for HMAC.
  • Rate limiting on 401 responses — separate hardening item from _docs/05_security/security_report.md.

Acceptance Criteria

AC-1: Anonymous request returns 401 Given the API is running with JWT_SECRET configured When any request to any endpoint (except Swagger metadata) is sent without an Authorization header Then the response is HTTP 401 Unauthorized.

AC-2: Expired token returns 401 Given a JWT signed with the configured secret but with exp in the past When the API receives a request carrying that token Then the response is HTTP 401 Unauthorized and the body identifies the failure reason at the WWW-Authenticate header level (no leakage of internal details in the response body).

AC-3: Invalid signature returns 401 Given a JWT whose payload has been tampered with so the HMAC signature no longer verifies When the API receives a request carrying that token Then the response is HTTP 401 Unauthorized.

AC-4: Valid token reaches the handler unchanged Given a JWT signed with the configured secret with exp in the future When the API receives a GET /api/satellite/tiles/latlon request carrying that token with valid query parameters Then the response is identical (status, headers other than Authorization-related, body) to the pre-AZ-487 behavior for the same request.

AC-5: Startup fails on missing or short secret Given JWT_SECRET is unset, empty, or shorter than 32 bytes When the API starts Then startup fails with a clear error message identifying the missing/invalid secret; the API does not bind to its port.

AC-6: HttpContext.User exposes claims Given a JWT containing claims sub, email, role, permissions When a handler reads HttpContext.User Then HttpContext.User.Identity.IsAuthenticated is true, the sub claim is present, and the permissions claim values are accessible via the standard claims API.

AC-7: Swagger UI works with the Authorize button Given the API is running in Development When the operator opens Swagger UI, clicks "Authorize", pastes a valid token, and triggers any endpoint Then the request is sent with the Bearer token and the response is whatever the handler would return for an authenticated request.

AC-8: All existing tests pass with the test token attached Given the test infrastructure attaches a valid dev-token to every test HTTP request When scripts/run-tests.sh --full runs Then all 213 unit tests + the existing 5 smoke scenarios + the new JWT integration tests pass.

Non-Functional Requirements

Performance

  • JWT validation per request must add < 1 ms overhead (HMAC-SHA256 + claims parsing); no cryptographic operations beyond what JwtBearer does by default. No caching required.

Compatibility

  • The HTTP contract change (every endpoint can now return 401) is documented in the architecture doc. No request/response schema field changes for valid-token callers.
  • Existing callers (gps-denied-onboard, mission planner UI) MUST coordinate to attach the Bearer token they already hold from the admin API. This is a coordination cost flagged as a deployment risk in Risks below.

Reliability

  • Missing/invalid JWT_SECRET is fail-fast at startup, never at first request — operators learn about the misconfiguration immediately, not on first user traffic.

Security

  • Secret length validation enforces ≥ 32 bytes — RFC 2104 §3 minimum for HMAC-SHA256.
  • RequireExpirationTime = true rejects tokens that omit exp (defense against forged "never-expires" tokens).
  • ClockSkew = TimeSpan.FromSeconds(30) is the maximum drift; do not relax further without explicit security review.

Unit Tests

AC Ref What to Test Required Outcome
AC-5 AddSatelliteJwt with missing Jwt:Secret configuration Startup throws a clear InvalidOperationException (or equivalent) naming the missing key
AC-5 AddSatelliteJwt with Jwt:Secret shorter than 32 bytes Startup throws with a message identifying the minimum length requirement
AC-1, AC-3, AC-4 JwtTokenFactory test helper round-trip via TokenValidationParameters Tokens minted by the helper validate; tampered tokens fail validation

Blackbox Tests

AC Ref Initial Data/Conditions What to Test Expected Behavior NFR References
AC-1 API running with valid JWT_SECRET Send GET /api/satellite/tiles/latlon?... with NO Authorization header HTTP 401 Compatibility
AC-2 API running with valid JWT_SECRET; expired token minted Send GET /api/satellite/region/{id} with the expired token HTTP 401
AC-3 API running with valid JWT_SECRET; tampered token Send any request with the tampered token HTTP 401 Security
AC-4 API running with valid JWT_SECRET; valid token Send GET /api/satellite/tiles/latlon?Latitude=...&Longitude=...&ZoomLevel=18 HTTP 200 with the tile metadata response (identical to pre-AZ-487 behavior) Performance
AC-7 API running in Development with Swagger UI accessible Open Swagger, Authorize with a token, invoke an endpoint Request includes Authorization: Bearer <token>, endpoint returns its normal response
AC-8 Full test environment Run scripts/run-tests.sh --full All tests pass

Constraints

  • The suite contract suite/_docs/10_auth.md is authoritative for token shape and signing key distribution. This task implements the validator side; if the contract changes (e.g., admin migrates to RS256), this is a follow-up task.
  • HMAC secret MUST come from the JWT_SECRET env var — never from a checked-in config file. The dev placeholder in appsettings.Development.json is acceptable because the file is committed and the placeholder is clearly fake; the JWT_SECRET env var (when set) overrides it.
  • No new cross-component ProjectReference — JWT plumbing lives entirely in SatelliteProvider.Api.
  • Issuer/audience claims are NOT validated in this PBI because the suite contract does not specify expected values. If the admin team confirms specific values later, add ValidateIssuer = true + ValidIssuer = <value> and same for audience as a small follow-up — not as part of this task.
  • ClockSkew tightened from the JwtBearer default (5 minutes) to 30 seconds; document the reason in code (defense in depth — operators in mixed-clock environments may need to override, but the default should be tight).

Risks & Mitigation

Risk 1: Existing callers break the moment AZ-487 deploys

  • Risk: gps-denied-onboard and mission planner UI currently call satellite-provider with no Authorization header. The instant .RequireAuthorization() lands, every existing call returns 401 — production-incident-grade breaking change.
  • Mitigation:
    • Coordinate the deploy with gps-denied-onboard and UI teams BEFORE merge: confirm both already hold a valid JWT from the admin API and can attach it to outbound calls.
    • Stage the deploy: ship to dev only first, validate that suite e2e tests pass, then promote to stage/prod.
    • Feature flag option (rejected during planning): a compile-time/config flag to bypass auth was considered. Decision: NO bypass flag — auth bypasses tend to become permanent. Coordinate the rollout instead.

Risk 2: Test infrastructure leaks the dev secret

  • Risk: Hard-coding the test secret in test source could let a careless copy-paste land it in production config.
  • Mitigation:
    • Test secret is ≥ 32 bytes BUT clearly tagged TEST-ONLY- prefix.
    • Test secret is read from an env var (SATELLITE_TEST_JWT_SECRET) in the test runner, not hard-coded — same separation pattern as production.
    • .gitignore already excludes .env; ensure no .env.test file is added without scrubbing.

Risk 3: JwtBearer package version drift vs Microsoft.AspNetCore.OpenApi

  • Risk: Picking a major version that mismatches the existing ASP.NET Core 8 packages introduces transitive-dependency conflicts at build time. The Security Audit also flagged a recommended bump of Microsoft.AspNetCore.OpenApi 8.0.21 → 8.0.25.
  • Mitigation:
    • Pin Microsoft.AspNetCore.Authentication.JwtBearer to the same minor version family as the rest of the ASP.NET Core 8 packages already in the project.
    • If the OpenApi bump from _docs/05_security/security_report.md happens in this PBI, align JwtBearer to the same version. Otherwise pin to the existing 8.0.21 series.

Risk 4: HS256 secret rotation invalidates all live tokens

  • Risk: The HMAC pattern means rotating JWT_SECRET invalidates every issued token across every API instance simultaneously — there's no overlap window unless the validator accepts both old and new secrets briefly.
  • Mitigation:
    • Out of scope for this PBI — operationally the suite's current rotation policy is "all tokens become invalid; all clients re-login". Document this in the WebApi component description so operators know.
    • If multi-secret support is needed later (admin team adds RS256 + JWKS), it's a follow-up task.

Cross-component coordination

This PBI changes the HTTP contract observed by:

  • gps-denied-onboard — must attach Bearer token to outbound calls; coordinate before merge.
  • mission planner UI — same.
  • Any other satellite-provider consumer not yet inventoried.

A smoke test in the suite-level e2e harness (suite/e2e/) MUST be updated alongside this PBI's deploy, OR temporarily skipped with an issue-tracked unskip task. Surface this to the suite repo owner during code review.