Batch 3 of test implementation cycle 1 (existing-code Step 6). - AZ-581 AuthClaimsTests: NFT-SEC-01..06+04b (foreign-keypair, byte-flip, 30s skew, iss/aud/perms, multi-value permissions array). - AZ-582 CrossCutting/ErrorRedaction/JwksRotation/StartupConfig/CorsConfig: NFT-SEC-07..13 (alg pin, kid rotation grace window, env fail-fast, CORS Production gate). - AZ-583 CascadeF3/CascadeF4/MigratorRestart: NFT-RES-01..04. CascadeF4 pins current walk-order divergence with carry_forward AC-4.6. - AZ-584 ConfigDbStartup/JwksRotationNoRestart/DefaultVehicleRace: NFT-RES-05..08. NFT-RES-08 pins current behaviour (unique-index closes the race) with carry_forward AC-1.4. Mock contract: SignBody accepts permissions OR permissions_array (mutually exclusive). TokenSigner validates kid_override against published keys so NFT-SEC-11 can assert "mock refuses old kid post-grace". Helpers added: ForeignKeypair (test-only ECDSA P-256), MissionsContainerHelper (docker-run wrapper for startup-time scenarios), DockerLogs. 7 of 22 new tests are Skippable, gated on COMPOSE_RESTART_ENABLED + docker CLI in the e2e-consumer image (explicit skip reason; no silent pass). Build green: test csproj + jwks-mock csproj. Co-authored-by: Cursor <cursoragent@cursor.com>
9.5 KiB
Security Tests — Auth & Claims
Task: AZ-581_test_security_auth_claims
Name: Security tests — auth & claims (NFT-SEC-01..06 + 04b)
Description: Implement xUnit blackbox tests for the 7 JWT authn/authz scenarios — missing/invalid header, invalid signature (single-byte flip + foreign-keypair), expired-outside-skew vs inside-30s-skew, wrong iss, wrong aud, missing permissions, wrong/multi-value permissions claim (contains-match accepts ["FL","ADMIN"]).
Complexity: 5 points
Dependencies: AZ-576_test_infrastructure
Component: Blackbox Tests
Tracker: AZ-581
Epic: AZ-575
Problem
JWT validation is the only thing standing between the open e2e-net and the protected /vehicles + /missions + /missions/{id}/waypoints surface. Six failure modes (no header / bad signature / expired / wrong iss / wrong aud / wrong perm) MUST all produce 401 or 403 deterministically — any drift means an attacker who learns the JWKS public bytes could shape a token that bypasses one rule and rides through. The drift re-verification of 2026-05-14 split AC-5.3 into two checks (iss AND aud) and tightened the clock skew from .NET's 5-min default to 30s; this task pins both. NFT-SEC-06 specifically asserts the RequireClaim("permissions","FL") is contains-match — a multi-permission token ["FL","ADMIN"] must be accepted, while "fl" / "FLight" / "ADMIN" alone must be rejected.
Outcome
- All seven NFT-SEC-01..06 + 04b scenarios run and pass against the dockerised
missionsservice. - Each test produces a CSV row with
Category=Sec,Traces=AC-5.xorAC-9.x,Result=pass. - NFT-SEC-02 covers BOTH the single-byte-flip case AND the foreign-keypair case (token signed by a separate ECDSA keypair never published in the JWKS).
- NFT-SEC-03 verifies the 30s skew BOTH ways —
exp_offset_seconds=-60rejected,exp_offset_seconds=-15accepted. - NFT-SEC-06 verifies multi-permission token acceptance —
permissions: ["FL","ADMIN"]→200. - NFT-SEC-01 asserts no DB side-effect on the
POST /vehicles401 path (side-channel count unchanged).
Scope
Included
- NFT-SEC-01 Missing
Authorizationheader on/vehiclesGET/POST,/missionsGET,/missions/{any}/waypointsGET — all401, no DB row written on the POST. - NFT-SEC-02 Invalid signature — single-byte-flipped signature segment AND foreign-keypair tokens.
- NFT-SEC-03 Expired token —
exp_offset_seconds=-60→401;exp_offset_seconds=-15→200(inside 30s skew). - NFT-SEC-04 Wrong
iss—POST /sign { "iss": "https://attacker.example.com" }→401; defaultiss→200. - NFT-SEC-04b Wrong
aud—POST /sign { "aud": "wrong-audience" }→401. - NFT-SEC-05 Missing
permissionsclaim —403. - NFT-SEC-06 Wrong
permissionsvalue AND multi-permission acceptance —"fl","FLight","ADMIN"→403;["FL","ADMIN"]→200.
Excluded
- NFT-SEC-07 health-exempt-from-auth lives in Task 15.
- NFT-SEC-08 stacktrace-not-leaked overlaps with FT-N-08 in Task 13 (and lives in Task 15 for the security-shaped variant).
- NFT-SEC-09 SQL injection guard lives in Task 15.
- NFT-SEC-10 alg-pin lives in Task 15.
- NFT-SEC-11 unknown-kid rotation lag lives in Task 15.
- NFT-SEC-12 missing-env startup throw lives in Task 15.
- NFT-SEC-13 CORS Production-gate lives in Task 15.
Acceptance Criteria
AC-1: NFT-SEC-01 missing header rejects every protected endpoint with 401, no side-effect
Given the running test stack
When the consumer issues GET /vehicles, GET /missions, GET /missions/{any}/waypoints, and POST /vehicles with a valid body — all without an Authorization header
Then each response is 401, AND side-channel SELECT COUNT(*) FROM vehicles before and after the POST are equal
AC-2: NFT-SEC-02 invalid signature rejects two attack shapes
Given a valid signed token T_good from jwks-mock POST /sign
When the consumer flips a single byte in T_good's signature segment producing T_bad, and separately mints T_foreign signed by an ECDSA keypair never published in the JWKS
Then GET /vehicles with T_bad returns 401 AND GET /vehicles with T_foreign returns 401
AC-3: NFT-SEC-03 30s clock skew is enforced on both sides
Given the mock with default issuer/audience
When the consumer mints two tokens via POST /sign { exp_offset_seconds: -60 } and POST /sign { exp_offset_seconds: -15 }
Then GET /vehicles with the −60s token returns 401 AND GET /vehicles with the −15s token returns 200
AC-4: NFT-SEC-04 wrong iss rejected, matching iss accepted
When the consumer mints a token via POST /sign { iss: "https://attacker.example.com" } and another via POST /sign {} (default iss)
Then GET /vehicles with the attacker-iss token returns 401 AND with the default-iss token returns 200
AC-5: NFT-SEC-04b wrong aud rejected
When the consumer mints a token via POST /sign { aud: "wrong-audience" }
Then GET /vehicles returns 401
AC-6: NFT-SEC-05 missing permissions claim rejected with 403
When the consumer mints a token with no permissions claim (mock body { permissions: "" } or { permissions: null } per the mock's contract)
Then GET /vehicles returns 403 (NOT 401 — signature is valid)
AC-7: NFT-SEC-06 contains-match policy on permissions
When the consumer mints tokens with permissions values "ADMIN", "fl" (lowercase), "FLight", AND ["FL","ADMIN"] (multi-value array)
Then GET /vehicles returns 403 for the first three AND 200 for the multi-value ["FL","ADMIN"] array (contains-match accepts "FL" among the values)
Non-Functional Requirements
Performance
- NFT-SEC-01..06: ≤ 5s each. The Authorization-header failure paths are cheap (no DB round-trip on the 401/403 short-circuit).
Reliability
- NFT-SEC-02 requires an out-of-band ECDSA-keypair helper that lives inside the test project, NOT in
jwks-mock(the mock must never publish a public key it does not control). The helper generates a P-256 keypair at test-start and signs a token directly usingSystem.Security.Cryptography.ECDsa— the public key is never registered withmissions.
Blackbox Tests
| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|---|---|---|---|---|
| AC-1 | running stack | 4 endpoints w/o Authorization | all 401; POST no DB write | AC-5.4 |
| AC-2 | T_good from mock + foreign keypair |
flipped signature; foreign-keypair token | both 401 | AC-5.5 |
| AC-3 | mock with default iss/aud | exp_offset −60s vs −15s | 401 / 200 | AC-5.2, AC-5.6 |
| AC-4 | mock | iss=attacker vs default | 401 / 200 | AC-5.3, AC-5.11 |
| AC-5 | mock | aud=wrong | 401 | AC-5.3, AC-5.12 |
| AC-6 | mock | permissions missing | 403 | AC-5.8, AC-9.1 |
| AC-7 | mock | permissions=ADMIN/fl/FLight/["FL","ADMIN"] | 403/403/403/200 | AC-9.1, AC-9.2 |
Constraints
- HTTP only against
http://missions:8080. Tokens minted viahttps://jwks-mock:8443/signwith parameterised overrides. - NFT-SEC-02 foreign-keypair: a test-only helper inside
Azaion.Missions.E2E.TestsMAY useSystem.Security.Cryptography.ECDsadirectly for the attack-token construction; this is the ONLY in-test signing path allowed — every other test must use the mock. - NFT-SEC-06 multi-permission token requires the mock's
POST /signbody to acceptpermissionsas either a string OR a JSON array; the test-infrastructure ticket (AZ-576) covers this in the mock's contract. - AAA pattern with
// Arrange/// Act/// Assertper test.
Risks & Mitigation
Risk 1: NFT-SEC-03 flaky due to wall-clock variability
- Risk: A −15s offset could fail if Docker time skew between the mock and
missionsis large. - Mitigation: Both containers run on the same host clock (no
--inittime isolation); test asserts only at offsets well clear of the 30s boundary (−60s and −15s — 30s and 15s away from the boundary respectively).
Risk 2: NFT-SEC-06 multi-permission shape varies between systems
- Risk: If the spec for
permissionsclaim later changes from "contains-match string" to "exact-array-membership", the multi-value assertion breaks. - Mitigation: Test traces explicitly to AC-9.2 and references
Auth/JwtExtensions.cspolicy registration; any change there must update this test in the same commit.
Risk 3: Foreign-keypair token validation might pass if the SUT silently trusts any well-formed ECDSA token
- Risk: A regression that disables
IssuerSigningKeyResolverwould let the foreign-keypair token through. - Mitigation: Mitigated by the structure of AC-2 — both bad-signature shapes (flipped byte AND foreign keypair) must return 401.
System Under Test Boundary
- Tests drive the product through the public HTTP surface (
http://missions:8080/{vehicles,missions}*) and acquire signed tokens viahttps://jwks-mock:8443/sign(with the test-only foreign-keypair helper for NFT-SEC-02). Expected outputs are the documented HTTP status codes from_docs/00_problem/input_data/expected_results/results_report.mdAC-5 rows and AC-9 rows. - Stubs are allowed ONLY for: the external
adminJWT issuer (jwks-mockcontainer). - Stubs, fakes, deterministic fallbacks, monkeypatches, or direct imports are NOT allowed for any internal product module — including
JwtExtensions,Program.cs(auth pipeline registration), the[Authorize(Policy = "FL")]filter, orErrorHandlingMiddleware. If any of these is not implemented, the test MUST fail/block as missing product implementation — it must not pass by replacing the module with a test stub.