[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,201 @@
# 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.