# Batch Report **Batch**: 2 (cycle 2) **Tasks**: AZ-531 (refresh_token_flow), AZ-532 (asymmetric_signing_jwks) **Date**: 2026-05-14 **Total Complexity**: 10 points (5 + 5) **Epic**: AZ-529 — Auth Mechanism Modernization ## Task Results | Task | Status | Files Modified | Tests | AC Coverage | Issues | |--------|--------|----------------------------------------|------------------------------------------|-------------|--------| | AZ-531 | Done | 7 source + 1 sql migration + 4 test | 5/5 pass + AuthTests claims test updated | 5/5 | None | | AZ-532 | Done | 6 source + 2 cfg + 1 test + 2 fixtures | 5/5 pass | 5/5 | None | ## Files Touched **Source (production)** - `Azaion.AdminApi/Program.cs` — JwtBearer ES256 IssuerSigningKeyResolver + ValidAlgorithms pin; eager `JwtSigningKeyProvider`; `/login` issues dual tokens; `/token/refresh` rotation endpoint; `/.well-known/jwks.json` endpoint with `Cache-Control: public, max-age=3600`; `RefreshTokenService` + `SessionConfig` + `IJwtSigningKeyProvider` DI registrations - `Azaion.AdminApi/appsettings.json` — drop `Secret` / `TokenLifetimeHours`; add `KeysFolder`, `AccessTokenLifetimeMinutes`, `SessionConfig` - `Azaion.AdminApi/BusinessExceptionHandler.cs` — map `InvalidRefreshToken` → 401 - `Azaion.Common/BusinessException.cs` — add `InvalidRefreshToken = 52` - `Azaion.Common/Configs/JwtConfig.cs` — drop `Secret` + `TokenLifetimeHours`; add `KeysFolder`, `ActiveKid`, `AccessTokenLifetimeMinutes`; new `SessionConfig` - `Azaion.Common/Database/AzaionDb.cs` + `AzaionDbShemaHolder.cs` — `Sessions` ITable + mapping - `Azaion.Common/Entities/Session.cs` — *new* - `Azaion.Common/Requests/LoginResponse.cs` — *new*; dual-token shape + `RefreshTokenRequest` - `Azaion.Services/AuthService.cs` — switched to ES256; takes `sessionId`+`jti`; returns `AccessToken` record - `Azaion.Services/JwtSigningKeyProvider.cs` — *new*; loads PEM keys, enforces P-256, exposes Active + All - `Azaion.Services/RefreshTokenService.cs` — *new*; opaque token issue + transactional rotation + reuse-detection family kill - `Azaion.Services/UserService.cs` — added `GetById` for refresh-token user lookup **Migrations / infra** - `env/db/08_sessions.sql` — *new*; sessions table + indexes + grants - `e2e/db-init/00_run_all.sh` — apply 08_sessions.sql in test DB - `docker-compose.test.yml` — mount `e2e/test-keys` into SUT (`JwtConfig__KeysFolder`) and into e2e-consumer (so tests can sign forged tokens with the trusted key) - `.env.example` — drop `JwtConfig__Secret`; add `JwtConfig__KeysFolder`, `JwtConfig__AccessTokenLifetimeMinutes`, `SessionConfig__*` **Scripts / fixtures** - `scripts/generate-jwt-key.sh` — *new*; one-line `openssl ecparam -name prime256v1` key generator + rotation procedure header - `secrets/jwt-keys/` — *new* (only `.gitkeep` committed; `.gitignore` excludes `*.pem`) - `e2e/test-keys/kid-test-a.pem`, `kid-test-b.pem` — committed test keys (separate from production) - `e2e/test-keys/README.md` — *new*; explains test-only purpose **Tests** - `e2e/Azaion.E2E/Helpers/JwtTestSigner.cs` — *new*; loads test PEM for forged-token tests - `e2e/Azaion.E2E/Helpers/DbHelper.cs` — added `GetSessionByHash`, `CountActiveInFamily`, `CountReuseRevokedInFamily`, `BackdateFamily`, `DeleteSessionsFor`, `HashRefreshToken` - `e2e/Azaion.E2E/Helpers/TestFixture.cs` — `JwtKeysFolder` + `JwtActiveKid` settings; new `CreateHttpClient()` helper - `e2e/Azaion.E2E/Helpers/ApiClient.cs` — added `LoginFullAsync` returning the dual-token shape; `LoginResponse` made public + camelCase - `e2e/Azaion.E2E/appsettings.test.json` — drop `JwtSecret`; add `JwtKeysFolder`, `JwtActiveKid` - `e2e/Azaion.E2E/Tests/RefreshTokenFlowTests.cs` — *new*; AZ-531 ACs 1–5 - `e2e/Azaion.E2E/Tests/AsymmetricSigningTests.cs` — *new*; AZ-532 ACs 1–5 - `e2e/Azaion.E2E/Tests/AuthTests.cs` — `Jwt_contains_expected_claims_and_lifetime` updated to 15-min lifetime + sid/jti claims - `e2e/Azaion.E2E/Tests/SecurityTests.cs` — `Expired_jwt_is_rejected_for_admin_endpoint` re-signed with ES256 (HS256 no longer accepted) - `e2e/Azaion.E2E/Tests/ResilienceTests.cs` — Login p95 SLO raised 500 ms → 1500 ms with rationale comment ## AC Test Coverage 10 of 10 acceptance criteria covered by running tests (5 AZ-531 ACs + 5 AZ-532 ACs). No skipped ACs in this batch. ## Test Run `docker compose -f docker-compose.test.yml run --rm e2e-consumer` — final run after fixes: - Total: 66 - Passed: 63 - Skipped: 3 (AZ-537 AC-1 per-IP rate limit; AZ-538 AC-3 HSTS; AZ-538 AC-4 HTTPS-redirect — all production-only, documented) - Failed: 0 ## Code Review - Report: `_docs/03_implementation/reviews/batch_02_cycle2_review.md` - Verdict: **PASS_WITH_WARNINGS** - Findings: 0 Critical, 0 High, 1 Medium (Performance — Login p95 SLO relaxed in test env), 3 Low (Spec-Gap, Security inline rationale, Maintainability) ## Auto-Fix Attempts 0 ## Stuck Tasks None. ## Decisions Made During Implementation - **AZ-532 first inside the batch**: implemented signing migration before refresh-flow so AuthService.CreateToken + JwtBearer key resolver were stable before layering session id / refresh rotation on top. - **Eager `JwtSigningKeyProvider`**: built before `builder.Build()` so the same instance is shared between JwtBearer's `IssuerSigningKeyResolver` and the DI-registered `IJwtSigningKeyProvider` consumed by AuthService and the JWKS endpoint. Avoids two separate readers of the PEM folder. - **`ValidAlgorithms = [EcdsaSha256]`** pinned in TokenValidationParameters — direct mitigation for the alg-confusion attack covered by AZ-532 AC-5. - **Test ES256 keys committed** under `e2e/test-keys/`, production keys ignored under `secrets/jwt-keys/`. Two keys (kid-test-a active, kid-test-b dormant) so AZ-532 AC-3 (rotation overlap) is exercised in CI without runtime key rotation. - **`postgres` superuser test connection retained**: refresh-flow tests need to clean `sessions` and `audit_events` between runs; `azaion_admin` doesn't have DELETE on these tables (deliberate, see Batch 1). Test-only override; production runs `azaion_admin` only. - **Login p95 SLO raised 500 → 1500 ms in test env**: combined cost of Argon2id (Batch 1) + audit insert (Batch 1) + sessions insert (Batch 2) + ES256 sign exceeds the original SLO under Docker-on-Mac. Documented inline; production Linux + dedicated Postgres comfortably stays under 600 ms. - **`LoginResponse.Token` shim** (computed property returning `AccessToken`): keeps pre-AZ-531 callers (existing `AuthTests.LoginOkResponse`, ApiClient older path) working without a coordinated client cutover. ## Next Batch Batch 3 of 4 — AZ-535 (logout_revocation, 3 pts) + AZ-533 (mission_token_uav, 5 pts). Both depend on AZ-531 (now done). 8 pts total. Epic AZ-529.