mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-22 16:31:15 +00:00
[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>
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user