[AZ-529] [AZ-530] Cycle-2 documentation refresh

Refreshes _docs/02_document/ to reflect the cycle-2 auth-modernization
+ CMMC hardening landings (AZ-531..AZ-538). Authoritative source for
the ripple set is ripple_log_cycle2.md.

Covered:
- architecture.md (section 1 rewritten, ADRs 6-9 added)
- data_model.md (sessions, audit_events, user columns, migrations)
- system-flows.md (F1 rewritten; F11-F17 added; F2/F7/F9 minor)
- module-layout.md (cycle-2 sub-component table)
- diagrams/flows/flow_login.md (dual-token + MFA)
- components/{01_data_layer,03_auth_and_security,05_admin_api}
- modules/ (12 new, 8 modified — full Argon2id/ES256/MFA/refresh
  /mission/session/audit/jwks rollup)
- tests/{blackbox,security,traceability-matrix}

Step 13 (Update Docs) output for cycle 2.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 09:22:53 +03:00
parent c2c659ef62
commit a77b3f8a59
35 changed files with 3624 additions and 468 deletions
+662
View File
@@ -811,3 +811,665 @@ The scenarios `FT-P-21`, `FT-P-22`, `FT-P-23` are retained here as ID placeholde
**Max execution time**: 5s
Note: AZ-197 AC-1 (resource download works without `Hardware`) is implicitly covered by the existing FT-P-09 / FT-P-10 scenarios once their request bodies are aligned with the new wire shape. AZ-197 AC-3..AC-8 are internal-signature / build-system invariants and are verified at build/CI time, not via a blackbox HTTP scenario.
---
## Cycle 2 Additions (2026-05-14) — Auth Modernization (AZ-529 + AZ-530)
The scenarios below were appended during the existing-code cycle 2 Test-Spec Sync (autodev Step 12) for the eight tasks under AZ-529 (Auth Mechanism Modernization) and AZ-530 (CMMC Compliance Hardening): AZ-531 (refresh-token flow), AZ-532 (asymmetric signing + JWKS), AZ-533 (mission-token UAV), AZ-534 (TOTP 2FA), AZ-535 (logout + revocation), AZ-536 (Argon2id), AZ-537 (rate-limit + lockout), AZ-538 (CORS HTTPS-only + HSTS). Numbering continues from FT-P-23 / FT-N-16. Security-only ACs live in `security-tests.md`.
### Argon2id Password Hashing (AZ-536)
#### FT-P-24: Legacy SHA-384 Password Still Validates
**Summary**: A user whose `password_hash` is in the pre-AZ-536 unsalted SHA-384 format can still log in with the correct password.
**Traces to**: AZ-536 AC-2
**Category**: Authentication
**Preconditions**:
- Seed user `legacy@azaion.com` with `password_hash` set to `Convert.ToBase64String(SHA384.HashData("LegacyPwd1!"))` (the historical format)
**Input data**: `{"email":"legacy@azaion.com","password":"LegacyPwd1!"}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /login with the legacy user's credentials | HTTP 200, dual-token body (per AZ-531) |
**Expected outcome**: HTTP 200, login succeeds against legacy hash format
**Max execution time**: 5s (note: Argon2id verify cost is incurred only on the post-login re-hash)
---
#### FT-P-25: Successful Legacy Login Re-Hashes to Argon2id
**Summary**: After FT-P-24 succeeds, the user's `password_hash` is silently upgraded to Argon2id PHC format and the same plaintext continues to validate.
**Traces to**: AZ-536 AC-3
**Category**: Authentication
**Preconditions**:
- FT-P-24 has just executed successfully for `legacy@azaion.com`
**Input data**: `{"email":"legacy@azaion.com","password":"LegacyPwd1!"}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | Read `users.password_hash` for `legacy@azaion.com` directly from DB | Value starts with `$argon2id$v=19$m=` and parses to m ≥ 65536, t ≥ 3, p ≥ 1 |
| 2 | POST /login with the same plaintext password again | HTTP 200, dual-token body |
**Expected outcome**: Hash format upgraded to Argon2id PHC; subsequent login still works
**Max execution time**: 5s
---
#### FT-N-17: Wrong Password Fails for Both Hash Formats
**Summary**: Wrong password is rejected with the same error (`WrongPassword`) regardless of whether the stored hash is legacy SHA-384 or Argon2id.
**Traces to**: AZ-536 AC-4
**Category**: Authentication
**Preconditions**:
- One user with legacy SHA-384 hash, one user with Argon2id hash already in DB
**Input data**: Wrong password against each user
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /login (legacy user, wrong pwd) | HTTP 409, ExceptionEnum=WrongPassword (code 30) |
| 2 | POST /login (Argon2id user, wrong pwd) | HTTP 409, ExceptionEnum=WrongPassword (code 30) |
**Expected outcome**: Same error code on both code paths; no information leak about hash format
**Max execution time**: 5s per attempt (Argon2id cost incurred regardless of success/failure)
---
### /login Rate Limit + Account Lockout (AZ-537)
#### FT-P-26: Successful Login Resets the Failed-Attempt Counter
**Summary**: After some wrong-password attempts (within budget), a successful login zeros `failed_login_count` and clears `lockout_until`.
**Traces to**: AZ-537 AC-4
**Category**: Authentication
**Preconditions**:
- User `alice@azaion.com` exists with Argon2id-hashed password
**Input data**: 5 wrong-password attempts followed by 1 correct attempt
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /login with wrong pwd × 5 (within rate-limit budget) | HTTP 409 each (WrongPassword) |
| 2 | Read `users.failed_login_count` for alice | Value = 5 |
| 3 | POST /login with correct pwd | HTTP 200, dual-token body |
| 4 | Read `users.failed_login_count` and `lockout_until` for alice | `failed_login_count = 0`, `lockout_until IS NULL` |
**Expected outcome**: Counter reset on success
**Max execution time**: 30s (5× Argon2id verifies)
---
#### FT-P-27: Lockout Auto-Expires After Configured Duration
**Summary**: A locked account becomes loginable again automatically once `lockout_until < now()`.
**Traces to**: AZ-537 AC-5
**Category**: Authentication
**Preconditions**:
- `Auth:Lockout:DurationMinutes` set to a small value (e.g. 1 minute) in the test env so the test does not have to wait 15 min
- User `bob@azaion.com` exists with Argon2id hash
**Input data**: 10 wrong attempts to trigger lockout, then a correct attempt after the duration window
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /login with wrong pwd × 10 | first 9 → 409 WrongPassword; the 10th → 423 Locked OR 409 followed by lockout flag |
| 2 | POST /login with correct pwd immediately | HTTP 423 Locked (account is locked) |
| 3 | Wait `Auth:Lockout:DurationMinutes + 1s` | — |
| 4 | POST /login with correct pwd | HTTP 200, dual-token body |
**Expected outcome**: 423 → 200 transition once the lockout window expires
**Max execution time**: 90s (depends on configured lockout duration in test env)
---
### CORS HTTPS-Only + HSTS (AZ-538)
#### FT-P-28: HTTPS Origin Preflight Succeeds
**Summary**: The CORS allow-list still admits the canonical `https://admin.azaion.com` origin and echoes the credentials flag.
**Traces to**: AZ-538 AC-2
**Category**: Cross-Origin
**Preconditions**:
- Admin API running with `AdminCorsPolicy` configured (post-AZ-538)
**Input data**:
- Method: OPTIONS
- Path: /login
- Header: `Origin: https://admin.azaion.com`
- Header: `Access-Control-Request-Method: POST`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | OPTIONS /login with the headers above | HTTP 204; `Access-Control-Allow-Origin: https://admin.azaion.com`; `Access-Control-Allow-Credentials: true` |
**Expected outcome**: HTTPS origin preflight succeeds with credentials flag
**Max execution time**: 5s
---
#### FT-P-29: Development Env — No HTTPS Redirect, No HSTS
**Summary**: When `ASPNETCORE_ENVIRONMENT=Development`, plain HTTP requests to localhost still serve 200 responses with no `Strict-Transport-Security` header.
**Traces to**: AZ-538 AC-5
**Category**: Cross-Origin
**Preconditions**:
- Admin API running with `ASPNETCORE_ENVIRONMENT=Development` (the default test container env)
**Input data**: GET http://localhost:8080/health/live
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | GET http://localhost:8080/health/live | HTTP 200; no `Strict-Transport-Security` header; no 307 redirect |
**Expected outcome**: Dev workflow preserved — no redirect, no HSTS
**Max execution time**: 5s
---
### Refresh-Token Flow (AZ-531)
#### FT-P-30: /login Returns Dual Tokens
**Summary**: Successful login returns both a short-lived access token (≈15 min) and an opaque refresh token; a `sessions` row is created.
**Traces to**: AZ-531 AC-1
**Category**: Authentication
**Preconditions**:
- Seed user without MFA enabled
**Input data**: Valid email + password
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /login | HTTP 200; body has `access_token` (JWT), `access_exp` ≈ now+15m ±60s, `refresh_token` (opaque ≥43 chars), `refresh_exp` |
| 2 | Decode `access_token` payload | Contains `sub`, `iss`, `aud`, `exp`, `jti`, `sid` claims |
| 3 | Query `sessions` table by `user_id` | Exactly one row with non-null `refresh_hash`, non-null `family_id`, `revoked_at IS NULL` |
**Expected outcome**: Dual tokens issued, session row persisted, access token has short TTL
**Max execution time**: 5s
---
#### FT-P-31: /token/refresh Rotates the Refresh Token
**Summary**: A valid refresh token is exchanged for a new access + new refresh; the previous refresh is invalidated; the session chain extends via `parent_session_id`.
**Traces to**: AZ-531 AC-2
**Category**: Authentication
**Preconditions**:
- FT-P-30 just produced refresh token R1
**Input data**: `{"refresh_token":"<R1>"}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /token/refresh with R1 | HTTP 200; body has new `access_token`, new `refresh_token` (R2 ≠ R1), new `access_exp`, new `refresh_exp` |
| 2 | POST /token/refresh with R1 again (same call) | HTTP 401 (R1 has been rotated; see AC-3 reuse-detection in NFT-SEC-08) |
| 3 | Inspect `sessions` table | Original row's `refresh_hash` rotated; new row has `parent_session_id` chained to the previous row |
**Expected outcome**: Rotation succeeds; old refresh dies; chain is preserved
**Max execution time**: 5s
---
#### FT-P-32: Refresh Sliding + Absolute Expiry
**Summary**: Refresh tokens slide on use up to the per-family absolute cap (12 h since the family's first issue); after the absolute cap, refresh fails.
**Traces to**: AZ-531 AC-4
**Category**: Authentication
**Preconditions**:
- A `sessions` family with `family_first_issued_at` set to `now() - 11h59m` (verified via DB seed) and a current valid refresh token R-current
**Input data**: `{"refresh_token":"<R-current>"}`, called near and past the absolute cap
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /token/refresh at family-age 11h59m | HTTP 200, rotation succeeds; sliding window extended |
| 2 | Seed another family with `family_first_issued_at = now() - 12h01s` | — |
| 3 | POST /token/refresh on that family | HTTP 401, body indicates absolute-expiry violation |
**Expected outcome**: Sliding works inside 12 h; absolute cap rejects beyond
**Max execution time**: 5s
---
### Asymmetric Signing + JWKS (AZ-532)
#### FT-P-33: GET /.well-known/jwks.json Serves the Active Public Key
**Summary**: The JWKS endpoint is anonymous, cacheable, and returns a well-formed JWKS containing the active EC P-256 public key with `kid`.
**Traces to**: AZ-532 AC-2
**Category**: Cryptography / Discovery
**Preconditions**:
- Admin running with an ES256 keypair loaded from `secrets/jwt_signing_key.pem`
**Input data**: None (anonymous GET)
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | GET /.well-known/jwks.json (no JWT) | HTTP 200; `Content-Type: application/json`; `Cache-Control: public, max-age=3600` |
| 2 | Parse body | `{"keys":[{"kty":"EC","crv":"P-256","kid":<non-empty>,"x":<base64url>,"y":<base64url>,"alg":"ES256","use":"sig"}, …]}` |
**Expected outcome**: JWKS shape matches RFC 7517; cache headers present
**Max execution time**: 5s
---
#### FT-P-34: Two-Key Overlap During Rotation
**Summary**: When two signing keys are configured (`kid-A` active + `kid-B` standby), JWKS exposes both; tokens signed with the active key continue to verify; switching the active flag to `kid-B` produces `kid-B`-stamped tokens that also verify.
**Traces to**: AZ-532 AC-3
**Category**: Cryptography / Rotation
**Preconditions**:
- Two keys configured in `secrets/`: `jwt_signing_key_a.pem` (active), `jwt_signing_key_b.pem` (standby)
**Input data**: Sequenced login + rotation toggle
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | GET /.well-known/jwks.json | Both `kid-A` and `kid-B` appear in `keys` array |
| 2 | POST /login | Returned access token has `kid: kid-A` in header |
| 3 | Toggle active key → `kid-B` (test-only admin endpoint or env reload) | — |
| 4 | POST /login again | Returned access token has `kid: kid-B` in header |
| 5 | Use either token against any protected endpoint | HTTP 200 (both verify against their respective public keys in JWKS) |
**Expected outcome**: Overlap window allows both keys; verifiers can keep working through rotation
**Max execution time**: 10s
---
### Mission-Token Issuance for UAV (AZ-533)
#### FT-P-35: POST /sessions/mission Issues a Long-Lived Mission Token
**Summary**: An authenticated pilot session can mint a mission-class access token with a duration ≈ `planned_duration_h + 1h` and no refresh token.
**Traces to**: AZ-533 AC-1
**Category**: Mission Sessions
**Preconditions**:
- Pilot user with valid (post-AZ-531) access token; MFA already proven within the session (post-AZ-534)
- Aircraft user `UAV-117` with `Role=CompanionPC` exists
**Input data**: `{"mission_id":"M-2026-05-14-042","aircraft_id":"UAV-117","planned_duration_h":9,"requested_scope":["GPS"]}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /sessions/mission with the body above + pilot access token | HTTP 200; body has `access_token`, no `refresh_token`, `exp` ≈ now + 10h ±60s |
| 2 | Decode token payload | `token_class = "mission"` |
| 3 | Query `sessions` table | Row with `class='mission'`, `aircraft_id='UAV-117'`, `revoked_at IS NULL` |
**Expected outcome**: Long-lived mission token issued; session persisted with class marker
**Max execution time**: 5s
---
#### FT-P-36: Mission Token Carries Scope Claims
**Summary**: The mission token's payload exposes `mission_id`, `aircraft_id`, `aud`, `permissions`, `sid`, `jti`.
**Traces to**: AZ-533 AC-3
**Category**: Mission Sessions
**Preconditions**:
- FT-P-35 just produced a mission token
**Input data**: The mission token from FT-P-35
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | Decode mission token payload | `mission_id == "M-2026-05-14-042"`, `aircraft_id == "UAV-117"`, `aud == "satellite-provider"`, `permissions` contains `"GPS"`, `sid` non-empty, `jti` non-empty |
**Expected outcome**: All scope claims present and correctly populated
**Max execution time**: 5s
---
#### FT-P-37: Mission Token Auto-Revoked on Aircraft Reconnect
**Summary**: When the aircraft user behind a mission session calls `/login` or `/token/refresh` again, every open mission session for that aircraft is marked `revoked_reason='post_flight_reconnect'` and the mission token stops working.
**Traces to**: AZ-533 AC-4
**Category**: Mission Sessions
**Preconditions**:
- Open mission session for `UAV-117` from FT-P-35 (token MT)
**Input data**: A `/login` from the `UAV-117` companion PC user
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /login as `UAV-117` (CompanionPC creds) | HTTP 200, dual tokens (per AZ-531) |
| 2 | Query `sessions` row for the original mission MT | `revoked_at` set; `revoked_reason = 'post_flight_reconnect'` |
| 3 | Use MT against any protected endpoint | HTTP 401 |
**Expected outcome**: Reconnect implicitly revokes outstanding mission sessions for the same aircraft
**Max execution time**: 10s
---
#### FT-N-18: POST /sessions/mission Requires Authentication
**Summary**: Without an Authorization header, mission-token issuance is rejected at the gateway.
**Traces to**: AZ-533 AC-5
**Category**: Mission Sessions
**Preconditions**: None
**Input data**: Same body as FT-P-35, no Authorization header
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /sessions/mission with no JWT | HTTP 401 |
**Expected outcome**: Unauthenticated mission requests are rejected
**Max execution time**: 5s
---
#### FT-N-19: POST /sessions/mission Rejects Over-Cap Duration
**Summary**: A request for `planned_duration_h > 12` is rejected with HTTP 400 and a descriptive error message.
**Traces to**: AZ-533 AC-2
**Category**: Mission Sessions
**Preconditions**:
- Authenticated pilot session (with MFA `amr=mfa`)
**Input data**: `{"mission_id":"M-2026-05-14-099","aircraft_id":"UAV-117","planned_duration_h":15,"requested_scope":["GPS"]}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /sessions/mission with the over-cap body | HTTP 400; response body contains `"planned_duration_h must be ≤ 12"` |
**Expected outcome**: 400 with cap-violation message; no session row created
**Max execution time**: 5s
---
### TOTP-Based 2FA at Login (AZ-534)
#### FT-P-38: POST /users/me/mfa/enroll Returns Usable Secret + Recovery Codes
**Summary**: A user without MFA can begin enrollment and receives a 32-char base32 TOTP secret, an `otpauth://` URL, a base64 PNG QR, and 10 recovery codes (≥12 chars each).
**Traces to**: AZ-534 AC-1
**Category**: MFA Enrollment
**Preconditions**:
- Authenticated user `mfauser@azaion.com`, `mfa_enabled = false`
**Input data**: `{"password":"<plaintext>"}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /users/me/mfa/enroll with the body above | HTTP 200; body has `secret` (32-char base32), `otpauth_url` (matches `^otpauth://totp/`), `qr_png_base64` (non-empty), `recovery_codes` (length = 10, each ≥ 12 chars, base32) |
| 2 | Read `users.mfa_enabled` for the user | Value still `false` (only flips after `confirm`) |
**Expected outcome**: Enrollment package returned; `mfa_enabled` not yet flipped
**Max execution time**: 5s
---
#### FT-P-39: POST /users/me/mfa/confirm Activates MFA
**Summary**: Submitting a valid TOTP code from the just-issued secret completes enrollment and flips `mfa_enabled = true`.
**Traces to**: AZ-534 AC-2
**Category**: MFA Enrollment
**Preconditions**:
- FT-P-38 just executed for the same user; the test holds the returned `secret`
**Input data**: `{"code":"<TOTP code computed from secret at current time>"}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | Compute current 6-digit TOTP from `secret` (RFC 6238, 30 s window) | 6 digits |
| 2 | POST /users/me/mfa/confirm with the code | HTTP 200 |
| 3 | Read `users.mfa_enabled` and `users.mfa_enrolled_at` | `mfa_enabled = true`, `mfa_enrolled_at` non-null |
**Expected outcome**: MFA activated; subsequent /login goes through the two-step flow
**Max execution time**: 5s
---
#### FT-P-40: Two-Step Login With TOTP
**Summary**: When a user has MFA enabled, `/login` returns an MFA-required envelope with a short-lived `mfa_token`; calling `/login/mfa` with the `mfa_token` + a valid TOTP code yields the real access + refresh; the access token's `amr` claim contains both `pwd` and `mfa`.
**Traces to**: AZ-534 AC-3
**Category**: Authentication / MFA
**Preconditions**:
- User from FT-P-39 (MFA enabled)
**Input data**: Valid email + password, then `mfa_token` + TOTP code
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /login with email + password | HTTP 200; body = `{ "mfa_required": true, "mfa_token": "<short-lived JWT>", "expires_in": 300 }`; no access/refresh present |
| 2 | POST /login/mfa with `{ "mfa_token": "<from step 1>", "code": "<TOTP>" }` | HTTP 200; body has access + refresh tokens |
| 3 | Decode access token | `amr` claim = `["pwd","mfa"]` |
**Expected outcome**: Two-step flow completes; access token's `amr` reflects both factors
**Max execution time**: 10s
---
#### FT-P-41: Recovery Code Substitutes for TOTP and Burns On Use
**Summary**: A recovery code may be used in place of a TOTP code at `/login/mfa`. The same code on a subsequent attempt fails (single-use). The successful access token's `amr` claim records `recovery`.
**Traces to**: AZ-534 AC-4
**Category**: Authentication / MFA
**Preconditions**:
- User from FT-P-39; the test holds the `recovery_codes` array from FT-P-38
**Input data**: First recovery code, then re-use of the same code
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /login → get `mfa_token` | HTTP 200, MFA-required envelope |
| 2 | POST /login/mfa with `{ "mfa_token", "code": "<recovery_codes[0]>" }` | HTTP 200, access + refresh issued; `amr` = `["pwd","mfa","recovery"]` |
| 3 | POST /login → get a new `mfa_token` | HTTP 200, MFA-required envelope |
| 4 | POST /login/mfa with the SAME recovery code | HTTP 401 (recovery code burned) |
**Expected outcome**: Recovery code works once, then is rejected
**Max execution time**: 10s
---
#### FT-P-42: POST /users/me/mfa/disable Removes MFA
**Summary**: Submitting password + a valid TOTP code disables MFA; subsequent `/login` returns access + refresh directly without the two-step flow.
**Traces to**: AZ-534 AC-5
**Category**: MFA Enrollment
**Preconditions**:
- User from FT-P-39
**Input data**: `{"password":"<plaintext>","code":"<TOTP>"}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /users/me/mfa/disable | HTTP 200 |
| 2 | Read `users.mfa_enabled` | `false` |
| 3 | POST /login with email + password | HTTP 200; body has access + refresh directly (no `mfa_required`) |
**Expected outcome**: MFA disabled, single-step login restored
**Max execution time**: 5s
---
### Logout + Revocation Surface (AZ-535)
#### FT-P-43: POST /logout Revokes the Current Session
**Summary**: A POST /logout with a valid access token marks the session row revoked and disables the paired refresh token.
**Traces to**: AZ-535 AC-1
**Category**: Session Lifecycle
**Preconditions**:
- Active session from a prior /login (access token A, refresh token R)
**Input data**: Authorization header `Bearer <A>`, empty body
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /logout with bearer A | HTTP 200 |
| 2 | Query the session row | `revoked_at` set; `revoked_reason = 'user_logout'` |
| 3 | POST /token/refresh with R | HTTP 401 |
**Expected outcome**: Session revoked, refresh dies immediately
**Max execution time**: 5s
---
#### FT-P-44: POST /logout/all Revokes Every Session for the User
**Summary**: A user with multiple active sessions can sign out of all of them in one call.
**Traces to**: AZ-535 AC-2
**Category**: Session Lifecycle
**Preconditions**:
- User with three active sessions S1/S2/S3 (each from a separate /login)
**Input data**: Authorization header `Bearer <A from S1>`, empty body
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /logout/all from S1 | HTTP 200 |
| 2 | Query `sessions` for the user | All three rows have `revoked_at` set |
| 3 | POST /token/refresh with the refresh tokens of S1/S2/S3 | All three return HTTP 401 |
**Expected outcome**: Every session for the user is revoked
**Max execution time**: 10s
---
#### FT-P-45: POST /sessions/{sid}/revoke Lets Admin Kill Any Session
**Summary**: An Admin-role JWT can revoke any other user's session by id; the revoked row records the admin's user id.
**Traces to**: AZ-535 AC-3
**Category**: Admin Session Management
**Preconditions**:
- Admin user with valid (post-AZ-531) access token
- Target user with active session SID-X
**Input data**: Authorization header `Bearer <admin access>`, path `/sessions/<SID-X>/revoke`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /sessions/SID-X/revoke as admin | HTTP 200 |
| 2 | Query the SID-X row | `revoked_at` set; `revoked_by_user_id` = admin's user id |
| 3 | POST /token/refresh with SID-X's refresh | HTTP 401 |
**Expected outcome**: Admin-driven revocation works and records actor
**Max execution time**: 5s
---
#### FT-P-46: GET /sessions/revoked?since=… Returns Recent, Non-Expired Revocations
**Summary**: A verifier identity (`Role=Service`) polls the snapshot endpoint and gets the recently-revoked, still-valid sessions; expired entries are auto-pruned.
**Traces to**: AZ-535 AC-4
**Category**: Verifier Snapshot
**Preconditions**:
- 5 sessions revoked in the last hour, 2 of which already have `exp < now()`
- Verifier identity (Service role) with valid bearer
**Input data**: Authorization header `Bearer <verifier access>`, query `?since=<unix-ts 1h ago>`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | GET /sessions/revoked?since=<ts> with verifier bearer | HTTP 200; `Cache-Control: no-cache`; body is JSON array of length 3 |
| 2 | Inspect each entry | `{ jti, sid, exp }` shape; no expired entries present |
**Expected outcome**: 3 non-expired revocations returned; expired ones pruned
**Max execution time**: 5s
---
#### FT-P-47: POST /logout Is Idempotent
**Summary**: Logging out a session that is already revoked returns 200 with `already_revoked: true` and does not write to the DB.
**Traces to**: AZ-535 AC-5
**Category**: Session Lifecycle
**Preconditions**:
- Already-revoked session from FT-P-43
**Input data**: Authorization header `Bearer <still-valid-but-stale access>`, empty body
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /logout again | HTTP 200; body `{ "already_revoked": true }` |
| 2 | Query the session row's `updated_at` (or equivalent audit column) | Unchanged from before step 1 |
**Expected outcome**: Idempotent — no second DB mutation
**Max execution time**: 5s
+307
View File
@@ -92,3 +92,310 @@ The `POST /resources/get/{dataFolder?}` endpoint that this test exercised was re
| 2 | Attempt POST /login with disabled user credentials | HTTP 409 or HTTP 403 |
**Pass criteria**: Disabled user cannot obtain a JWT token
---
## Cycle 2 Additions (2026-05-14) — Auth Modernization (AZ-529 + AZ-530)
The scenarios below were appended during the existing-code cycle 2 Test-Spec Sync (autodev Step 12) for the security-only / cryptography-invariant ACs in cycle 2. Functional flows live in `blackbox-tests.md` under the matching task. Numbering continues from NFT-SEC-06.
### NFT-SEC-07: New User Hashes Use Argon2id (AZ-536)
**Summary**: A freshly-registered user's `password_hash` is in Argon2id PHC format with parameters at or above the configured floor.
**Traces to**: AZ-536 AC-1
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | POST /users (ApiAdmin JWT) registering `freshuser@azaion.com` with a known password | HTTP 200 |
| 2 | Read `users.password_hash` for `freshuser@azaion.com` directly from Postgres | Value starts with `$argon2id$v=19$m=` |
| 3 | Parse the PHC string parameters | `m ≥ 65536`, `t ≥ 3`, `p ≥ 1` |
**Pass criteria**: All new users land in Argon2id PHC format with at least the configured cost parameters; no SHA-384 base64 strings written for new accounts.
---
### NFT-SEC-08: Argon2id Verify Has No Remotely Observable Timing Leak (AZ-536)
**Summary**: `VerifyPassword` is constant-time across wrong passwords of various lengths; timing variance does not leak information about the candidate password.
**Traces to**: AZ-536 AC-5
**Preconditions**:
- User with Argon2id-hashed password
- Test environment with low concurrency (this test is sensitive to host noise — if it intermittently trips, widen the bound or warm Argon2 with a non-test login first; see cycle-2 carry-forward F6)
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | POST /login with a wrong 8-char password, sample N=20 timings | Each → HTTP 409 WrongPassword |
| 2 | POST /login with a wrong 64-char password, sample N=20 timings | Each → HTTP 409 WrongPassword |
| 3 | Compute median of each sample; compare | `|median_8 median_64| / median_8 < 0.20` (within 20% of each other — Argon2id cost dominates string-comparison cost) |
**Pass criteria**: Wrong-password verify time is dominated by Argon2id cost, not by string-length-dependent comparison; no exploitable timing channel.
---
### NFT-SEC-09: Per-IP Rate Limit Returns 429 (AZ-537)
**Summary**: 11 `/login` requests from the same client IP within 60 s force the 11th into HTTP 429 with a `Retry-After` header.
**Traces to**: AZ-537 AC-1
**Preconditions**:
- Rate-limit `Auth:RateLimit:PerIp` set to 10 / 60 s sliding (the test env value)
- Test client preserves source IP across requests (E2E container-shared-IP caveat applies — see test_run_report cycle 2 skip note for the legitimate environment-mismatch skip)
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | POST /login × 10 from the same IP within 5 s (any mix of right/wrong passwords) | HTTP 200 / HTTP 409 (within budget) |
| 2 | POST /login as the 11th request inside the 60 s window | HTTP 429; response includes `Retry-After` header (integer seconds) |
**Pass criteria**: 11th request inside the window is rejected with 429 + Retry-After. (Legitimate environment-mismatch skip in shared-IP container envs — verified by ASP.NET Core RateLimiter unit tests + manual probe documented in AZ-537 spec.)
---
### NFT-SEC-10: Per-Account Rate Limit Returns 429 (AZ-537)
**Summary**: 6 `/login` requests for the same email from 6 different IPs within 5 min force the 6th into HTTP 429.
**Traces to**: AZ-537 AC-2
**Preconditions**:
- Rate-limit `Auth:RateLimit:PerAccount` set to 5 / 5 min sliding
- Test ability to spoof / vary the source IP per request (e.g. via `X-Forwarded-For` if the app trusts a known forwarder, or a multi-host test fixture)
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | POST /login for `alice@azaion.com` from IPs 1..5 within 1 min (any mix of right/wrong passwords) | HTTP 200 / HTTP 409 (within budget) |
| 2 | POST /login for `alice@azaion.com` from IP 6 inside the 5 min window | HTTP 429; `Retry-After` present |
**Pass criteria**: Per-account partition triggers independently of per-IP partition.
---
### NFT-SEC-11: Account Lockout Returns 423 Even For Correct Password (AZ-537)
**Summary**: Once `failed_login_count` hits the lockout threshold, the account returns HTTP 423 Locked even for subsequent correct-password attempts until `lockout_until` passes.
**Traces to**: AZ-537 AC-3
**Preconditions**:
- `Auth:Lockout:MaxAttempts = 10` (default)
- User `bob@azaion.com` with Argon2id-hashed password
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | POST /login for `bob@azaion.com` with wrong password × 10 (across IPs / within rate budget) | First 9 → HTTP 409 WrongPassword; 10th → HTTP 423 Locked OR final 409 followed by lockout flag |
| 2 | Read `users.lockout_until` and `users.failed_login_count` for `bob` | `lockout_until > now()`; counter at threshold |
| 3 | POST /login for `bob` with correct password immediately after | HTTP 423 Locked (lockout precedes credential check) |
**Pass criteria**: Lockout state takes precedence over correct credentials within the lockout window; counter persists across IPs (per-account, not per-IP).
---
### NFT-SEC-12: Lockout Is Audit-Logged (AZ-537)
**Summary**: When NFT-SEC-11 fires the lockout transition, an audit-log row is written with the email, source IP, and timestamp.
**Traces to**: AZ-537 AC-6
**Preconditions**:
- Audit log infrastructure online (verified by existing logging tests)
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Trigger NFT-SEC-11 against `bob@azaion.com` from IP `203.0.113.7` | Lockout fires |
| 2 | Query the audit log for entries with `event = 'login_lockout'` since the test start | At least one row with `email = 'bob@azaion.com'`, `ip = '203.0.113.7'`, `timestamp` within ± 5 s of the lockout trigger |
**Pass criteria**: Each lockout produces a `login_lockout` audit entry with the security-relevant fields.
---
### NFT-SEC-13: HTTP CORS Origin Is Rejected (AZ-538)
**Summary**: A browser preflight from the cleartext `http://admin.azaion.com` origin must NOT receive an `Access-Control-Allow-Origin` header (CORS denies the request).
**Traces to**: AZ-538 AC-1
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | OPTIONS /login with `Origin: http://admin.azaion.com`, `Access-Control-Request-Method: POST` | HTTP 204 OR 200; response has NO `Access-Control-Allow-Origin` header |
**Pass criteria**: HTTP origin gets no ACAO header — browser-side fetch with credentials will fail in any compliant browser.
---
### NFT-SEC-14: HSTS Header Present in Production (AZ-538)
**Summary**: When `ASPNETCORE_ENVIRONMENT=Production`, every HTTPS response includes a strict `Strict-Transport-Security` header.
**Traces to**: AZ-538 AC-3
**Preconditions**:
- Admin container running with `ASPNETCORE_ENVIRONMENT=Production`
- Note: the default test harness runs `Development`; this test must be run with the production env override OR is the legitimate environment-mismatch skip documented in cycle-2 test_run_report
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | GET https://admin.azaion.com/health/live (or any HTTPS endpoint) | HTTP 200; response header `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload` |
**Pass criteria**: Production responses always carry HSTS with the documented directives.
---
### NFT-SEC-15: HTTP Request Redirects to HTTPS in Production (AZ-538)
**Summary**: When `ASPNETCORE_ENVIRONMENT=Production`, a cleartext HTTP request returns HTTP 307 to the same path on HTTPS.
**Traces to**: AZ-538 AC-4
**Preconditions**: Same as NFT-SEC-14
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | GET http://admin.azaion.com/health/live | HTTP 307; `Location: https://admin.azaion.com/health/live` |
**Pass criteria**: HTTP traffic is redirected at the protocol layer, not silently served.
---
### NFT-SEC-16: Refresh-Token Reuse Kills the Session Family (AZ-531)
**Summary**: If a previously-rotated refresh token is presented again, the entire `sessions` family chain (parent + all descendants) is marked `revoked_reason='reuse_detected'` and every refresh in that family stops working.
**Traces to**: AZ-531 AC-3
**Preconditions**:
- A session family with refresh R1 rotated to R2 (per FT-P-31)
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | POST /token/refresh with R1 (already rotated) | HTTP 401 |
| 2 | Query `sessions` for the family | Every row in the family has `revoked_at` set; `revoked_reason = 'reuse_detected'` |
| 3 | POST /token/refresh with R2 | HTTP 401 (R2 also dead — family-wide kill) |
**Pass criteria**: Reuse detection kills the entire family, not just the reused refresh.
---
### NFT-SEC-17: Refresh Tokens Are Opaque, Not JWT (AZ-531)
**Summary**: Refresh tokens issued by /login or /token/refresh are not JWTs; the persisted form is the SHA-256 hash; the raw value never appears in logs.
**Traces to**: AZ-531 AC-5
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | POST /login → capture `refresh_token` R | R is a non-empty string ≥ 43 chars (base64url of 32 bytes) |
| 2 | Attempt to parse R as a JWT (split on `.` and base64url-decode the segments) | Parse fails — R does not split into a JWT header/payload/signature shape |
| 3 | Read the matching `sessions.refresh_hash` column directly from Postgres | Length 32 bytes (SHA-256 raw or base64-encoded), value ≠ R |
| 4 | Grep API logs (Serilog output) for the literal R | No match (raw refresh value never logged) |
**Pass criteria**: Refresh tokens are opaque, hashed at rest, and never logged in raw form.
---
### NFT-SEC-18: Admin Tokens Are Signed With ES256 + kid (AZ-532)
**Summary**: An access token returned by /login has `alg=ES256` and a `kid` matching one of the active JWKS keys.
**Traces to**: AZ-532 AC-1
**Preconditions**:
- Admin running with at least one ES256 keypair loaded from `secrets/jwt_signing_key.pem`
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | POST /login with valid credentials | HTTP 200, dual tokens |
| 2 | Decode the access token's JOSE header | `alg == "ES256"`, `kid` non-empty |
| 3 | GET /.well-known/jwks.json | The same `kid` appears in the returned `keys` array |
**Pass criteria**: Tokens are signed asymmetrically and carry the `kid` discriminator needed for rotation.
---
### NFT-SEC-19: JWKS Endpoint Never Exposes Private Material (AZ-532)
**Summary**: The JWKS payload contains only public components; no `d`, `p`, `q`, `dp`, `dq`, or `qi` field appears.
**Traces to**: AZ-532 AC-4
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | GET /.well-known/jwks.json | HTTP 200, JSON body |
| 2 | Inspect every entry in `keys` for forbidden private-material fields | None of `d`, `p`, `q`, `dp`, `dq`, `qi` is present |
**Pass criteria**: Public-key set strictly excludes any private scalar (EC) or RSA private primes.
---
### NFT-SEC-20: alg-Confusion Attack Is Rejected (AZ-532)
**Summary**: A forged token with `alg=HS256` (where the signature is computed using the public key as the HMAC secret) is rejected by every protected endpoint, because `TokenValidationParameters.ValidAlgorithms` pins ES256 only.
**Traces to**: AZ-532 AC-5
**Preconditions**:
- Test fixture able to construct a forged JWT given the public key
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Build a JWT with header `{ "alg":"HS256","typ":"JWT","kid":"<active-kid>" }`; payload claims valid; signature = HMAC-SHA256(publicKeyBytes, signingInput) | Forged token string |
| 2 | GET /users with the forged token | HTTP 401 |
**Pass criteria**: Algorithm-confusion forgery is rejected; verifier does not silently downgrade to HS256.
---
### NFT-SEC-21: Mission Token Requires MFA Step-Up (AZ-533 + AZ-534)
**Summary**: After AZ-534 ships, `POST /sessions/mission` MUST reject access tokens whose `amr` does not include `mfa`. Caller gets 403 with a step-up message.
**Traces to**: AZ-533 AC-6
**Preconditions**:
- AZ-534 already landed (it has — cycle 2 batch 4)
- Caller holds an access token with `amr=["pwd"]` (e.g. legacy session, or a service account that doesn't enroll MFA)
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | POST /sessions/mission with the `amr=["pwd"]` access token + a valid mission body | HTTP 403; response body contains `"mission tokens require step-up MFA"` |
**Pass criteria**: Mission-class tokens cannot be minted without MFA in the access-token `amr` chain.
> Note: cycle-2 follow-up F1 in `_docs/03_implementation/implementation_report_auth_modernization_cycle2.md` calls out that `/sessions/mission` enforcement of `amr=mfa` is the small wire-up still pending after AZ-534 shipped (the AC was deferred during AZ-533, then re-opened under F1). Until F1 lands, this scenario is the spec contract; the matching test may be marked Pending in the SUT.
---
### NFT-SEC-22: TOTP Secret Is Encrypted at Rest (AZ-534)
**Summary**: The `users.mfa_secret` column never holds plaintext base32; only ciphertext.
**Traces to**: AZ-534 AC-6
**Preconditions**:
- An enrolled user from FT-P-39
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Read `users.mfa_secret` for the enrolled user directly from Postgres | Value is non-empty |
| 2 | Try to base32-decode the value as if it were a 32-char TOTP secret | Decode either fails OR yields material that does NOT round-trip to a working TOTP code |
| 3 | Confirm the value is the output of `IDataProtector.Protect(<plaintext base32>)` (length ≫ 32 chars; format-prefixed) | Matches `IDataProtector` ciphertext shape |
**Pass criteria**: `mfa_secret` is stored encrypted; reading the DB row alone does not yield a usable TOTP secret. (Operational note: production must set `DataProtection:KeysFolder` for the `IDataProtector` to outlive container restarts — see cycle-2 carry-forward F3.)
@@ -137,3 +137,99 @@ The encrypted-download and installer-download endpoints were removed as obsolete
| Cycle-2 AC-4 | `ExceptionEnum` no longer carries `WrongResourceName` (50); the gap is preserved | — | Build/CI invariant — verified by enum read |
| Cycle-2 AC-5 | `Azaion.Test` project no longer in solution; build is clean | — | Build invariant — `dotnet build Azaion.AdminApi.sln` clean post-cleanup |
| Cycle-2 AC-6 | E2E suite passes after the test deletions above | All e2e tests | Covered by Step 11 Run Tests post-cleanup (2026-05-14) |
## Cycle 2 Additions (2026-05-14) — Auth Modernization (AZ-529 + AZ-530)
Appended during the existing-code cycle 2 Test-Spec Sync (autodev Step 12) for the eight tasks delivered by the auth-modernization + CMMC-hardening epics. Rows below are namespaced by tracker ID; functional scenarios live in `blackbox-tests.md`, security-only invariants in `security-tests.md`. Existing AC/test IDs from earlier cycles are preserved unchanged.
### AZ-536 — Argon2id Password Hashing (epic AZ-530, 5 ACs)
| AC ID | Acceptance Criterion | Test IDs | Coverage |
|-------|---------------------|----------|----------|
| AZ-536 AC-1 | New users get Argon2id hashes (PHC, m ≥ 64 MiB, t ≥ 3, p ≥ 1) | NFT-SEC-07 | Covered |
| AZ-536 AC-2 | Legacy SHA-384 hashes still validate | FT-P-24 | Covered |
| AZ-536 AC-3 | Successful legacy login transparently re-hashes to Argon2id | FT-P-25 | Covered |
| AZ-536 AC-4 | Wrong password fails for both formats with the same error code | FT-N-17 | Covered |
| AZ-536 AC-5 | Verify is constant-time (no remotely observable timing leak) | NFT-SEC-08 | Covered (with known suite-concurrency flake — see cycle-2 carry-forward F6) |
### AZ-537 — /login Rate Limit + Account Lockout (epic AZ-530, 6 ACs)
| AC ID | Acceptance Criterion | Test IDs | Coverage |
|-------|---------------------|----------|----------|
| AZ-537 AC-1 | Per-IP rate limit triggers HTTP 429 with `Retry-After` | NFT-SEC-09 | Covered (legitimate environment-mismatch skip in shared-IP container env) |
| AZ-537 AC-2 | Per-account rate limit triggers HTTP 429 across IPs | NFT-SEC-10 | Covered |
| AZ-537 AC-3 | Account lockout after 10 failures returns 423 even on correct password | NFT-SEC-11 | Covered |
| AZ-537 AC-4 | Successful login resets `failed_login_count` and clears `lockout_until` | FT-P-26 | Covered |
| AZ-537 AC-5 | Lockout auto-expires after configured duration | FT-P-27 | Covered |
| AZ-537 AC-6 | Audit-log entry written on each lockout event | NFT-SEC-12 | Covered |
### AZ-538 — CORS HTTPS-Only + HSTS (epic AZ-530, 5 ACs)
| AC ID | Acceptance Criterion | Test IDs | Coverage |
|-------|---------------------|----------|----------|
| AZ-538 AC-1 | HTTP origin gets no `Access-Control-Allow-Origin` header | NFT-SEC-13 | Covered |
| AZ-538 AC-2 | HTTPS origin preflight echoes credentials flag | FT-P-28 | Covered |
| AZ-538 AC-3 | HSTS header present in production responses | NFT-SEC-14 | Covered (legitimate Production-only environment-mismatch skip in dev test harness — verified by code inspection of `Program.cs UseHsts`) |
| AZ-538 AC-4 | HTTP request returns 307 to HTTPS in production | NFT-SEC-15 | Covered (legitimate Production-only environment-mismatch skip in dev test harness — verified by code inspection of `Program.cs UseHttpsRedirection`) |
| AZ-538 AC-5 | Development env unchanged (no redirect, no HSTS) | FT-P-29 | Covered |
### AZ-531 — Refresh-Token Flow (epic AZ-529, 5 ACs)
| AC ID | Acceptance Criterion | Test IDs | Coverage |
|-------|---------------------|----------|----------|
| AZ-531 AC-1 | `/login` returns dual tokens, session row persisted | FT-P-30 | Covered |
| AZ-531 AC-2 | `/token/refresh` rotates refresh + chains via `parent_session_id` | FT-P-31 | Covered |
| AZ-531 AC-3 | Reuse-detection kills the entire session family | NFT-SEC-16 | Covered |
| AZ-531 AC-4 | Sliding window + 12 h absolute family expiry | FT-P-32 | Covered |
| AZ-531 AC-5 | Refresh tokens are opaque, hashed at rest, never logged in raw form | NFT-SEC-17 | Covered |
### AZ-532 — Asymmetric Signing + JWKS (epic AZ-529, 5 ACs)
| AC ID | Acceptance Criterion | Test IDs | Coverage |
|-------|---------------------|----------|----------|
| AZ-532 AC-1 | Access tokens carry `alg=ES256` + `kid` | NFT-SEC-18 | Covered |
| AZ-532 AC-2 | `GET /.well-known/jwks.json` serves the active public key with cache headers | FT-P-33 | Covered |
| AZ-532 AC-3 | Two-key overlap during rotation (both JWKS entries valid) | FT-P-34 | Covered |
| AZ-532 AC-4 | JWKS never exposes private material | NFT-SEC-19 | Covered |
| AZ-532 AC-5 | alg-confusion forgery (HS256 with public key as secret) is rejected | NFT-SEC-20 | Covered |
### AZ-533 — Mission-Token Issuance for UAV (epic AZ-529, 6 ACs)
| AC ID | Acceptance Criterion | Test IDs | Coverage |
|-------|---------------------|----------|----------|
| AZ-533 AC-1 | Mission token issued with correct lifetime (`planned_duration_h + 1h`) | FT-P-35 | Covered |
| AZ-533 AC-2 | Hard cap of 12 h enforced (HTTP 400 with cap message) | FT-N-19 | Covered |
| AZ-533 AC-3 | Mission token carries `mission_id`, `aircraft_id`, `aud`, `permissions`, `sid`, `jti` | FT-P-36 | Covered |
| AZ-533 AC-4 | Mission session auto-revoked when aircraft user reconnects | FT-P-37 | Covered |
| AZ-533 AC-5 | Endpoint requires authenticated session | FT-N-18 | Covered |
| AZ-533 AC-6 | MFA step-up required (`amr` must include `mfa`) | NFT-SEC-21 | **Spec only** — pending wire-up post-AZ-534 (cycle-2 carry-forward F1) |
### AZ-534 — TOTP-Based 2FA at Login (epic AZ-529, 6 ACs)
| AC ID | Acceptance Criterion | Test IDs | Coverage |
|-------|---------------------|----------|----------|
| AZ-534 AC-1 | Enrollment returns secret + QR + 10 recovery codes | FT-P-38 | Covered |
| AZ-534 AC-2 | Confirm with valid TOTP completes enrollment | FT-P-39 | Covered |
| AZ-534 AC-3 | Two-step `/login``/login/mfa` flow; access-token `amr=["pwd","mfa"]` | FT-P-40 | Covered |
| AZ-534 AC-4 | Recovery code substitutes for TOTP and is single-use | FT-P-41 | Covered |
| AZ-534 AC-5 | Disable requires password + valid TOTP | FT-P-42 | Covered |
| AZ-534 AC-6 | TOTP secret encrypted at rest in `users.mfa_secret` | NFT-SEC-22 | Covered |
### AZ-535 — Logout + Revocation Surface (epic AZ-529, 5 ACs)
| AC ID | Acceptance Criterion | Test IDs | Coverage |
|-------|---------------------|----------|----------|
| AZ-535 AC-1 | `POST /logout` revokes the current session and kills refresh | FT-P-43 | Covered |
| AZ-535 AC-2 | `POST /logout/all` revokes every session for the user | FT-P-44 | Covered |
| AZ-535 AC-3 | Admin can revoke any session by id; row records actor | FT-P-45 | Covered |
| AZ-535 AC-4 | `GET /sessions/revoked?since=…` returns recent, non-expired entries | FT-P-46 | Covered |
| AZ-535 AC-5 | `POST /logout` is idempotent (no second DB write) | FT-P-47 | Covered |
## Cycle 2 Coverage Update
| Category | Total Items | Covered | Not Yet Wired | Coverage % |
|----------|-----------|---------|---------------|-----------|
| Acceptance Criteria (cycle 2 — auth modernization) | 43 | 42 | 1 (AZ-533 AC-6 — pending wire-up F1) | 98% |
| Acceptance Criteria — combined total (baseline + cycle 1 + cycle 2 cleanup + cycle 2 auth) | 100 | 96 | 1 (F1) + 3 baseline restrictions still uncovered | 96% |
The single uncovered cycle-2 AC (AZ-533 AC-6) is documented in the cycle-2 implementation report as carry-forward item F1 — the `/sessions/mission` `amr=mfa` enforcement was deferred during AZ-533, became implementable once AZ-534 shipped, and is filed as a follow-up ticket to be picked up in a later cycle.