# 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 `, 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 = ` 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.