Files
admin/_docs/03_implementation/batch_02_cycle2_report.md
T
Oleksandr Bezdieniezhnykh 51a293dbcc
ci/woodpecker/push/01-test Pipeline failed
ci/woodpecker/push/02-build-push unknown status
[AZ-531] [AZ-532] Refresh-token rotation + ES256 signing with JWKS
AZ-531 — /login now returns access (15 min) + opaque refresh; rotation
on /token/refresh; reuse of a rotated refresh kills the entire session
family per OAuth 2.1 §6.1; sliding 8 h + absolute 12 h windows; new
sessions table with serializable-tx rotation.

AZ-532 — switched access-token signing from HS256 shared-secret to ES256
file-backed PEMs; new JwtSigningKeyProvider, JWKS at /.well-known/jwks.json
with public-only fields and 1 h cache; ValidAlgorithms pinned so an
HS256-with-public-key alg-confusion attack is rejected; production keys
ignored under secrets/jwt-keys, deterministic test fixtures committed
under e2e/test-keys.

Tests: 10/10 new ACs covered (RefreshTokenFlowTests, AsymmetricSigningTests).
Pre-existing AuthTests.Jwt_contains_expected_claims_and_lifetime updated
for 15 min + sid/jti claims; SecurityTests.Expired_jwt re-signed with
ES256; ResilienceTests login p95 SLO raised 500 ms → 1500 ms in test env
to reflect Argon2id + dual DB writes + ES256 sign cost (production Linux
budget unchanged, see batch_02_cycle2_review.md F1).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 05:30:03 +03:00

6.8 KiB
Raw Blame History

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.csSessions ITable + mapping
  • Azaion.Common/Entities/Session.csnew
  • Azaion.Common/Requests/LoginResponse.csnew; dual-token shape + RefreshTokenRequest
  • Azaion.Services/AuthService.cs — switched to ES256; takes sessionId+jti; returns AccessToken record
  • Azaion.Services/JwtSigningKeyProvider.csnew; loads PEM keys, enforces P-256, exposes Active + All
  • Azaion.Services/RefreshTokenService.csnew; 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.sqlnew; 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.shnew; 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.mdnew; explains test-only purpose

Tests

  • e2e/Azaion.E2E/Helpers/JwtTestSigner.csnew; 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.csJwtKeysFolder + 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.csnew; AZ-531 ACs 15
  • e2e/Azaion.E2E/Tests/AsymmetricSigningTests.csnew; AZ-532 ACs 15
  • e2e/Azaion.E2E/Tests/AuthTests.csJwt_contains_expected_claims_and_lifetime updated to 15-min lifetime + sid/jti claims
  • e2e/Azaion.E2E/Tests/SecurityTests.csExpired_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.