[AZ-494] Enable JWT iss/aud validation with fail-fast startup

Option B per user decision: production ships with empty Jwt.Issuer /
Jwt.Audience in appsettings.json so the API process refuses to start
unless JWT_ISSUER + JWT_AUDIENCE env vars are supplied. Development
ships with grep-friendly DEV-ONLY- placeholders so local + docker
flows keep working unchanged.

AuthenticationServiceCollectionExtensions flips ValidateIssuer +
ValidateAudience to true and wires ValidIssuer / ValidAudience via a
new ResolveRequiredOrThrow helper that all three required values
(secret, iss, aud) now share. JwtTokenFactory.Create + CreateExpired
gain optional iss / aud parameters (default null) so existing call
sites compile unchanged. JwtTestHelpers adds MintAuthenticated /
MintExpired wrappers that resolve iss + aud from env, plus
ResolveIssuerOrThrow / ResolveAudienceOrThrow. PerfBootstrap.MintToken
+ Program.cs JWT bootstrap migrated to the new surface so the perf
harness and the integration runner both validate against the same
contract.

Adds 4 fail-fast unit tests (missing/empty issuer + audience), 2
negative integration scenarios (WrongIssuer_Returns401,
WrongAudience_Returns401), and re-tags every existing integration
mint site via MintAuthenticated.

Compose, .env.example, run-tests.sh, run-performance-tests.sh all
load + export JWT_ISSUER + JWT_AUDIENCE alongside JWT_SECRET.

Resolves F-AUTH-2 (security_report.md + owasp_review.md). AC-7
(cross-repo suite/_docs/10_auth.md write) deferred — outside this
workspace; tracked in deploy_cycle2.md R3 follow-up.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-12 02:28:48 +03:00
parent 080441db5d
commit f979e18811
27 changed files with 543 additions and 57 deletions
@@ -0,0 +1,89 @@
# Batch Report — Batch 05 cycle 3
**Batch**: 05 (cycle 3)
**Tasks**: AZ-494 (JWT iss/aud validation — enable + configure)
**Date**: 2026-05-12
## Task Results
| Task | Status | Files Modified | Tests | AC Coverage | Issues |
|------|--------|---------------|-------|-------------|--------|
| AZ-494_jwt_iss_aud_validation | Done (Option B) | 1 prod source + 1 TestSupport + 4 IntegrationTests + 1 unit test + 4 config + 4 scripts/compose + 6 docs | 4 new unit tests + 2 new integration scenarios; existing 13 unit + 8 integration cases re-tagged to mint via env iss/aud | 6/7 ACs addressed; AC-7 deferred (cross-repo `suite/_docs/10_auth.md` write) | 0 blockers; 1 Low (cross-repo doc), 1 acknowledged operational gate (admin team must supply real prod iss/aud — fail-fast at deploy enforces this). |
## AC Test Coverage: 6 of 7 addressed (AC-7 deferred — cross-repo)
## Code Review Verdict: pending (this batch report precedes per-batch review)
## Auto-Fix Attempts: 0
## Stuck Agents: None
## What was implemented
The task spec offered three options for handling the blocker (admin team has not yet confirmed production `iss` / `aud` values). The user selected **Option B**: implement the full validation plumbing now with **clearly-tagged DEV-only values** in `appsettings.Development.json` so local tests work, but leave `appsettings.json` empty so production deploys without explicit `JWT_ISSUER` / `JWT_AUDIENCE` environment variables fail at startup, not at runtime.
The implementation therefore:
1. Enables `ValidateIssuer = true` and `ValidateAudience = true` in the production token-validation pipeline.
2. Sources both values from `JWT_ISSUER` / `JWT_AUDIENCE` env vars with `Jwt:Issuer` / `Jwt:Audience` config keys as fallback (same resolution pattern as `JWT_SECRET` from AZ-487).
3. Throws `InvalidOperationException` at startup if either value is missing or whitespace — the message names the env var, the config key, and the AZ-494 task spec.
4. Threads `iss` / `aud` through the canonical `SatelliteProvider.TestSupport.JwtTokenFactory.Create` surface (the post-AZ-491 single source of truth) so every existing test path continues to mint matching tokens.
5. Adds a thin convenience layer in `SatelliteProvider.IntegrationTests/JwtTestHelpers` (`MintAuthenticated`, `MintExpired`, `ResolveIssuerOrThrow`, `ResolveAudienceOrThrow`) so integration test call sites stay terse and centrally fail-fast on missing env vars.
6. Adds two new negative integration tests (`WrongIssuer_Returns401`, `WrongAudience_Returns401`) and four new unit fail-fast tests (`AddSatelliteJwt_ThrowsOnMissingIssuer` / `_ThrowsOnEmptyIssuer` / `_ThrowsOnMissingAudience` / `_ThrowsOnEmptyAudience`).
7. Updates security artefacts (`security_report.md` flips F-AUTH-2 to **RESOLVED**, `owasp_review.md` A07 reflects same), the architecture + module docs (`architecture.md`, `modules/api_program.md`, `modules/tests_integration.md`, `modules/tests_unit.md`), the cycle-2 deploy report (R3 follow-up note), and the traceability matrix (5 new rows for AZ-494 AC-1..AC-7).
### Added
- `SatelliteProvider.IntegrationTests/JwtTestHelpers.cs` — three new public helpers:
- `ResolveIssuerOrThrow()` / `ResolveAudienceOrThrow()` — mirror the existing `ResolveSecretOrThrow` pattern (read env, throw `InvalidOperationException` with a humanised message if missing).
- `MintAuthenticated(...)` — convenience wrapper: defaults issuer + audience to the env-resolved values, accepts explicit overrides for negative test cases.
- `MintExpired(...)` — convenience wrapper for the existing `JwtTokenFactory.CreateExpired` overload, same env-resolution behaviour.
- 4 unit tests in `SatelliteProvider.Tests/Authentication/AuthenticationServiceCollectionExtensionsTests.cs`: `AddSatelliteJwt_ThrowsOnMissingIssuer`, `AddSatelliteJwt_ThrowsOnEmptyIssuer`, `AddSatelliteJwt_ThrowsOnMissingAudience`, `AddSatelliteJwt_ThrowsOnEmptyAudience`.
- 2 integration tests in `SatelliteProvider.IntegrationTests/JwtIntegrationTests.cs`: `WrongIssuer_Returns401`, `WrongAudience_Returns401`.
### Modified
- `SatelliteProvider.Api/Authentication/AuthenticationServiceCollectionExtensions.cs` — added `JwtIssuerEnvVar` / `JwtIssuerConfigKey` / `JwtAudienceEnvVar` / `JwtAudienceConfigKey` constants; flipped `ValidateIssuer` / `ValidateAudience` to `true` and wired `ValidIssuer` / `ValidAudience`; extracted a single `ResolveRequiredOrThrow` helper that all three required values now flow through.
- `SatelliteProvider.TestSupport/JwtTokenFactory.cs``Create(...)` and `CreateExpired(...)` gained optional `issuer` / `audience` parameters defaulted to `null` (old call sites still produce identical tokens; new call sites pass real values).
- `SatelliteProvider.IntegrationTests/PerfBootstrap.cs``MintToken()` now also resolves iss + aud and passes them through to `JwtTokenFactory.Create`. Without this the perf harness's bearer token would fail validation against the AZ-494-hardened API.
- `SatelliteProvider.IntegrationTests/Program.cs` — JWT bootstrap now resolves all three required values (secret + iss + aud) inside a single `try/catch` and prints them at startup. The `MintAuthenticated` helper replaces the inline `JwtTokenFactory.Create` call that used to live in `Main`.
- `SatelliteProvider.IntegrationTests/JwtIntegrationTests.cs``AnonymousRequest_*`, `ExpiredToken_Returns401`, `InvalidSignature_Returns401`, `ValidToken_Returns200_OnHealthyEndpoint` all migrated to `JwtTestHelpers.MintAuthenticated` / `JwtTestHelpers.MintExpired`. Adds the two new scenarios to `RunAll`.
- `SatelliteProvider.IntegrationTests/UavUploadTests.cs` — every `JwtTokenFactory.Create(...)` call replaced with `JwtTestHelpers.MintAuthenticated(...)`. Direct `using SatelliteProvider.TestSupport;` dropped (no longer needed at this seam).
- `SatelliteProvider.Tests/Authentication/AuthenticationServiceCollectionExtensionsTests.cs` — existing `_ConfiguresTokenValidationParameters_AsPerContract` and `_PrefersEnvironmentVariableOverConfiguration` cases updated to assert the AZ-494 contract; `BuildValidConfiguration()` now seeds iss + aud; static `[Fact]` setup/teardown saves and restores both env vars.
- `SatelliteProvider.Api/appsettings.json` — added empty `Jwt.Issuer` and `Jwt.Audience` keys (the fail-fast contract requires env-var or non-empty config; empty here forces ops to supply env vars in prod).
- `SatelliteProvider.Api/appsettings.Development.json` — placeholder dev values prefixed `DEV-ONLY-` so a grep for that prefix surfaces every "remember to replace" site.
- `.env.example` — documents `JWT_ISSUER` and `JWT_AUDIENCE` with the fail-fast contract and a one-line example value pair (same `DEV-ONLY-` prefix).
- `docker-compose.yml` / `docker-compose.tests.yml``JWT_ISSUER` and `JWT_AUDIENCE` now passed through to both the `api` and `integration-tests` services.
- `scripts/run-tests.sh` / `scripts/run-performance-tests.sh``.env` load + fail-fast checks for both new vars mirror the existing `JWT_SECRET` flow; both are exported so Docker Compose and the perf bootstrap see them.
### Documentation
- `_docs/02_document/architecture.md` — token contract bullet + Security Architecture authentication paragraph updated.
- `_docs/02_document/modules/api_program.md` — JWT authentication section + Configuration section.
- `_docs/02_document/modules/tests_integration.md` — env-var prerequisites updated; `JwtIntegrationTests` and `JwtTestHelpers` entries describe the new AZ-494 surface.
- `_docs/02_document/modules/tests_unit.md``AuthenticationServiceCollectionExtensionsTests` entry now lists the four AZ-494 fail-fast cases plus the updated config-precedence and contract assertions.
- `_docs/02_document/tests/traceability-matrix.md` — 5 new rows for AC-1..AC-7 (AC-7 marked deferred); the AZ-487 NFR rows updated to acknowledge the AZ-494 extension.
- `_docs/03_implementation/deploy_cycle2.md` — R3 follow-up note marked **RESOLVED in cycle 3 (AZ-494)** with the residual operational gate spelled out.
- `_docs/05_security/security_report.md` — F-AUTH-2 flipped to **RESOLVED cycle 3 (AZ-494)**; verdict reconciliation + recommendations updated.
- `_docs/05_security/owasp_review.md` — A07 row updated; new Low finding for residual "no token revocation list" gap noted as a separate follow-up.
## AC verification
| AC | Description | Verification |
|---|---|---|
| AC-1 | Wrong `iss` token returns 401 | `JwtIntegrationTests.WrongIssuer_Returns401` (integration; runtime gate at Step 16) |
| AC-2 | Wrong `aud` token returns 401 | `JwtIntegrationTests.WrongAudience_Returns401` (integration; runtime gate at Step 16) |
| AC-3 | Matching iss + aud accepted | `JwtIntegrationTests.ValidToken_Returns200_OnHealthyEndpoint` retains its assertion; tokens now minted via env-resolved iss/aud through `MintAuthenticated` |
| AC-4 | Missing config fails fast | 4 new unit tests in `AuthenticationServiceCollectionExtensionsTests`; manual `docker compose up` without env vars throws `InvalidOperationException` per the contract |
| AC-5 | Existing tests pass with matched fixtures | All `JwtTokenFactory.Create` direct call sites in the integration project removed in favour of `MintAuthenticated` (verified via `Grep`); unit suite still mints via the factory with explicit iss/aud |
| AC-6 | Security artefacts updated | `security_report.md` + `owasp_review.md` updated this batch |
| AC-7 | Suite-level contract reflects validation | **Deferred**`suite/_docs/10_auth.md` lives in the parent monorepo, outside this workspace. Cross-repo write is out of scope for satellite-provider's autodev. `deploy_cycle2.md` notes the cross-repo obligation. |
## Static / process checks
- `dotnet format whitespace --verify-no-changes` will run as part of `scripts/run-tests.sh` at Step 16.
- `ReadLints` on every modified C# file returned 0 warnings.
- Repo-wide grep for `JwtTokenFactory.Create` confirms only `SatelliteProvider.Tests` (unit, which intentionally exercises the factory directly with explicit iss/aud) + `PerfBootstrap.MintToken` + `JwtTestHelpers.MintAuthenticated` / `MintExpired` call it now — the integration suite never bypasses the env-resolution wrapper.
- `.env.example` keeps the `DEV-ONLY-` prefix grep-friendly so a future ops review can surface every placeholder site at once.
## Risks & follow-ups
- **Operational gate** (intentional, by Option B) — production deploy WITHOUT `JWT_ISSUER` + `JWT_AUDIENCE` env vars will fail at process start with the `InvalidOperationException` message documented above. This is the controlled deploy-time forcing function for admin-team confirmation.
- **Cross-repo doc** (AC-7) — `suite/_docs/10_auth.md` write deferred. Will surface as a `_docs/_process_leftovers/` entry if the suite repo still needs the update after this autodev finishes.
+1 -1
View File
@@ -133,7 +133,7 @@ Promote to `stage` / `main` only after the consumer-coordination items in R1 + R
The cycle-2 audit (Step 14) flagged 2 new Medium findings — both bounded by mitigations and tracked as follow-ups, NOT blockers:
- **F-AUTH-2** — `iss`/`aud` not validated. Coordinate with admin team to define the values; flip `ValidateIssuer`/`ValidateAudience` to `true` in a small follow-up PBI when ready.
- **F-AUTH-2** — `iss`/`aud` not validated. **RESOLVED in cycle 3 (AZ-494)** — code changes landed; `ValidateIssuer`/`ValidateAudience` now `true` against env-sourced `JWT_ISSUER` / `JWT_AUDIENCE`. The remaining operational item is admin-team confirmation of the production iss/aud values, which is gated by the fail-fast contract (production deploy without those values fails at startup, not at runtime).
- **F-UAV-1 / F-DEPS-UAV** — ImageSharp 3.1.11 now decodes attacker-controlled JPEGs. Today's mitigations (magic-byte gate, size cap, scoped `try/catch`) are sufficient against current advisories. Subscribe to GHSA for `SixLabors.ImageSharp`; patch within 7 days of any new CVE.
Cycle-1 carry-overs (S1, S2, S4, D1, I3, I5) are unchanged — still flagged in `_docs/05_security/security_report.md` as the pre-public-network hardening backlog.
@@ -0,0 +1,80 @@
# Code Review — Batch 05 cycle 3 (AZ-494)
**Reviewer**: autodev in-band
**Scope**: AZ-494 (JWT iss/aud validation) — implementation, tests, config, docs
**Verdict**: **PASS_WITH_WARNINGS** — 0 Critical, 0 High, 0 Medium, 1 Low (cross-repo doc), 1 acknowledged operational gate by user decision.
## 1. Spec compliance
All ACs except AC-7 are addressed. AC-7 (suite-level contract) requires writing to a file in the parent monorepo (`suite/_docs/10_auth.md`), which sits outside this workspace and outside autodev's blast radius. The cycle-2 deploy report has been updated to document the cross-repo obligation so it doesn't get lost.
The user's Option B selection is honoured exactly:
- `appsettings.json` ships with empty `Jwt.Issuer` / `Jwt.Audience` → production deploy without env vars fails at startup.
- `appsettings.Development.json` ships with `DEV-ONLY-`-prefixed placeholders → local + Docker-compose flows work without touching `.env`.
- The env-var precedence + fail-fast pattern is identical to the existing `JWT_SECRET` flow from AZ-487, so there's no second mental model for operators to learn.
## 2. Code quality
| Area | Observation | Verdict |
|---|---|---|
| Single source of truth | `JwtTokenFactory.Create` remains the only `new JwtSecurityToken(...)` call site in the source tree (verified by grep). The new iss/aud params slot in alongside the existing `secret` / `subject` / `lifetime` / `extraClaims` / `algorithm` parameters — no parallel code path was introduced. | ✓ |
| Helper layering | `JwtTestHelpers.MintAuthenticated` / `MintExpired` are thin wrappers (≤ 12 lines each) that resolve iss/aud from env and delegate to the factory. They live in `SatelliteProvider.IntegrationTests` because they read env vars — exactly the runner-side concern documented in `module-layout.md`. | ✓ |
| Fail-fast extraction | The third copy of the "resolve required value, throw with a humanised message" pattern was extracted into a single `ResolveRequiredOrThrow` helper. `ResolveSecretOrThrow` remains separate because it has the additional ≥ 32-byte size check from AZ-487. This is acceptable: two distinct invariants. | ✓ |
| Test mutual-isolation | Each new `[Fact]` saves and restores both `JWT_ISSUER` and `JWT_AUDIENCE` via `try/finally`, mirroring the existing pattern for `JWT_SECRET`. No parallel-test interference risk. | ✓ |
| Surface area minimisation | `JwtTokenFactory.Create` gained two optional, nullable parameters at the END of the signature with defaults of `null`. Old callers behave identically. | ✓ |
| Error message clarity | All three fail-fast messages name (a) the env var, (b) the config key, (c) the AZ-494 spec, and use the human label ("JWT issuer", "JWT audience"). | ✓ |
| Logging | No new debug/trace logs added — only the existing startup-time `InvalidOperationException` path. This matches `coderule.mdc`. | ✓ |
| Naming | `MintAuthenticated` / `MintExpired` / `ResolveIssuerOrThrow` / `ResolveAudienceOrThrow` all describe caller intent precisely. No vague "data"/"item"/"candidate" names. | ✓ |
## 3. Security
| Aspect | Status |
|---|---|
| F-AUTH-2 (iss/aud not validated) | **Resolved**`ValidateIssuer = true` + `ValidateAudience = true`, both wired to real values |
| Token revocation list | Still absent — listed as a new Low finding in `owasp_review.md` A07 |
| Secret leakage | No iss/aud value is logged at WARN / ERROR / INFO. Only the startup banner (Console.Out) prints them — intentional, for diagnosis. | ✓ |
| Production safety | `appsettings.json` empty → process won't start without explicit env vars. Cannot accidentally ship a hard-coded prod issuer. | ✓ |
| Test/prod isolation | `DEV-ONLY-` prefix means a grep can surface placeholder values at any time. | ✓ |
## 4. Performance
No measurable impact. `TokenValidationParameters` now checks two additional string comparisons per request. Sub-microsecond per request — far below the 1 ms AZ-487 NFR budget. PT-07 / PT-08 from AZ-492 will pick up any aggregate regression at the next perf run.
## 5. Cross-task consistency
- AZ-487 (JWT base layer) — extended, not replaced. The `RequireSignedTokens` / `RequireExpirationTime` / `ClockSkew = 30 s` / secret ≥ 32 bytes invariants all remain.
- AZ-491 (consolidate JWT test helpers) — the new `MintAuthenticated` / `MintExpired` wrappers strengthen the AZ-491 contract (delegate to the canonical factory; no parallel mint logic).
- AZ-492 (perf harness) — `PerfBootstrap.MintToken` was updated in the same way as integration call sites. Without this update the perf harness would have started emitting 401s after AZ-494 landed.
- AZ-493 (DB reset hook) — unaffected; runs against the same env.
- AZ-495 (doc folder convention) — unaffected.
- AZ-496 (bump aspnetcore 8.0.25) — directly relevant: AZ-494's `ValidateIssuer` / `ValidateAudience` semantics are unchanged across `Microsoft.AspNetCore.Authentication.JwtBearer` 8.0.21 → 8.0.25.
## 6. Findings
### Low
**L1 — Cross-repo doc deferred (AC-7)**
- Where: `suite/_docs/10_auth.md` (outside this workspace).
- Why noted: AC-7 explicitly calls for an update there; the autodev process treats this as out-of-scope per the workspace-boundary rule.
- Disposition: documented in `deploy_cycle2.md`'s R3 follow-up section.
### Acknowledged operational gate (NOT a finding)
The Option B contract is **deliberately** that production deploys without real iss/aud values fail at startup. That's the user-selected forcing function for admin-team confirmation. Not a code defect.
## 7. AC-by-AC re-check
| AC | Verification | Verdict |
|---|---|---|
| AC-1 | `JwtIntegrationTests.WrongIssuer_Returns401` asserts 401 against `https://wrong-issuer.invalid/` | ✓ |
| AC-2 | `JwtIntegrationTests.WrongAudience_Returns401` asserts 401 against `wrong-audience-not-satellite` | ✓ |
| AC-3 | `JwtIntegrationTests.ValidToken_Returns200_OnHealthyEndpoint` mints through `MintAuthenticated` (env-sourced iss/aud) | ✓ |
| AC-4 | 4 fail-fast unit tests + manual smoke ("docker compose up without env vars") path documented | ✓ |
| AC-5 | All integration test call sites migrated to `MintAuthenticated`; verified via grep | ✓ |
| AC-6 | `security_report.md` + `owasp_review.md` updated | ✓ |
| AC-7 | Cross-repo write deferred; documented | ◐ deferred |
## 8. Verdict
**PASS_WITH_WARNINGS** — proceed to ship. The single Low finding is structurally outside this workspace.