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>
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 Unauthorizedwhen the request has noAuthorization: Bearer …header, an expired token, or a token whose HMAC signature does not verify against theJWT_SECRETenv 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.Userclaims 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_SECRETenv var is documented inappsettings.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 existingMicrosoft.AspNetCore.OpenApipackage — currently 8.0.x; if a security upgrade is happening alongside, pin both consistently). - Configures
AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options => …)withTokenValidationParameters: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_SECRETis missing or shorter than 32 bytes (HMAC-SHA256 minimum-secure key size).
- Adds
- New ServiceCollection extension call wired into
Program.cs:builder.Services.AddSatelliteJwt(builder.Configuration);followed bybuilder.Services.AddAuthorization();and the matchingapp.UseAuthentication(); app.UseAuthorization();middleware ordering (afterUseCors, before endpoint routing). .RequireAuthorization()applied to every existingMapGet/MapPostinProgram.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.csSwagger configuration extended with aSecurityDefinition("Bearer", ...)and a globalSecurityRequirementso the "Authorize" button appears in Swagger UI.appsettings.jsonandappsettings.Development.json: addJwt:Secretconfiguration key readingJWT_SECRETvia the standard ASP.NET Core env-var binding. The dev file ships a 32+ byte placeholder secret clearly markedDEV-ONLY-DO-NOT-USE-IN-PROD.docker-compose.yml: addJWT_SECRETto the api service'senvironmentblock, sourcing from the host env (or a.enventry the operator supplies)..env.example(create if missing): includeJWT_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 withJWT_SECRETset 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.
- Add a small test helper
- New unit tests:
AuthenticationServiceCollectionExtensionsTests:AddSatelliteJwt_RegistersJwtBearerScheme,AddSatelliteJwt_ThrowsOnMissingSecret,AddSatelliteJwt_ThrowsOnShortSecret.- Token factory:
JwtTokenFactory_ProducesTokenAcceptedByValidationParameters.
- New integration tests:
JwtIntegrationTests.AnonymousRequest_To_AnyEndpoint_Returns401JwtIntegrationTests.ExpiredToken_Returns401JwtIntegrationTests.InvalidSignature_Returns401JwtIntegrationTests.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.mdto describe the JWT middleware ordering and where.RequireAuthorization()is applied. - Update
_docs/02_document/modules/api_program.mdto reflect the new middleware chain. - Reference (do not duplicate)
suite/_docs/10_auth.mdfrom this task spec and from the WebApi component description.
- Update
Excluded
GET /users/meandPUT /users/meendpoints — 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
permissionsclaim containsGPSfor 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
401responses — 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
JwtBearerdoes 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_SECRETis 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 = truerejects tokens that omitexp(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.mdis 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_SECRETenv var — never from a checked-in config file. The dev placeholder inappsettings.Development.jsonis acceptable because the file is committed and the placeholder is clearly fake; theJWT_SECRETenv var (when set) overrides it. - No new cross-component
ProjectReference— JWT plumbing lives entirely inSatelliteProvider.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-onboardand mission planner UI currently call satellite-provider with noAuthorizationheader. The instant.RequireAuthorization()lands, every existing call returns 401 — production-incident-grade breaking change. - Mitigation:
- Coordinate the deploy with
gps-denied-onboardand 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
devonly first, validate that suite e2e tests pass, then promote tostage/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.
- Coordinate the deploy with
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. .gitignorealready excludes.env; ensure no.env.testfile is added without scrubbing.
- Test secret is ≥ 32 bytes BUT clearly tagged
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.JwtBearerto 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.mdhappens in this PBI, align JwtBearer to the same version. Otherwise pin to the existing8.0.21series.
- Pin
Risk 4: HS256 secret rotation invalidates all live tokens
- Risk: The HMAC pattern means rotating
JWT_SECRETinvalidates 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.