chore: update configuration and Docker setup for JWT and test results
ci/woodpecker/push/build-arm Pipeline was successful

Enhanced the .gitignore to exclude test results and updated the Dockerfile to include a new entrypoint script for improved container initialization. Refactored JWT configuration to support additional parameters for automatic refresh intervals, ensuring better control over token management. Updated the ConfigurationResolver to enforce required environment variables without hardcoded fallbacks, enhancing security and flexibility.
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-15 03:23:23 +03:00
parent 7025f4d075
commit 78dea8ebab
40 changed files with 1990 additions and 510 deletions
@@ -1,15 +1,16 @@
# Flow F5 — JWT bearer validation
> Cross-cutting flow that runs on every `[Authorize]` request. Local validation only — this service never calls back to the issuing `admin` service.
> Cross-cutting flow that runs on every `[Authorize]` request. **ECDSA-SHA256 asymmetric validation against public keys cached from `admin`'s JWKS endpoint.** `admin` is contacted once at startup (and on JWKS refresh) for the JWKS document; subsequent request-path validation is local and does not call `admin`.
## Description
ASP.NET Core's `JwtBearerHandler` validates incoming `Authorization: Bearer <jwt>` headers against the shared HMAC secret (`JWT_SECRET`). On success, the request continues to the controller with a `ClaimsPrincipal` attached. On signature / lifetime failure → `401`. On valid token but missing `"FL"` permission claim → `403`. The `iss` / `aud` claims are intentionally NOT validated today (CMMC L2 finding tracked at suite level under AZ-487 / AZ-494 — see `05_identity` § Implementation Details).
ASP.NET Core's `JwtBearerHandler` validates incoming `Authorization: Bearer <jwt>` headers using public ECDSA keys cached locally from `admin`'s JWKS endpoint. On success, the request continues to the controller with a `ClaimsPrincipal` attached. On signature / lifetime / `iss` / `aud` / `alg` failure → `401`. On valid token but missing required permission claim → `403`. Both `iss` and `aud` are validated against the resolved `JWT_ISSUER` / `JWT_AUDIENCE` values; the signing algorithm is pinned to `EcdsaSha256` (see `05_identity` § Implementation Details for the rationale).
## Preconditions
- `JWT_SECRET` is resolved at startup (env or hardcoded dev fallback per `architecture.md` ADR-005).
- `AddJwtAuth(jwtSecret)` was called during `Program.cs` startup (F6).
- `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL` are all resolved at startup via `ConfigurationResolver.ResolveRequiredOrThrow`. Any missing value aborts startup before the host is built.
- `AddJwtAuth(builder.Configuration)` was called during `Program.cs` startup (F6); this also wired the `ConfigurationManager<JsonWebKeySet>` against the resolved JWKS URL.
- For the **first** protected request after process start, the cached JWKS is empty; the `IssuerSigningKeyResolver` synchronously fetches it from `admin`. After that fetch, subsequent requests use the cached keys until the manager's next refresh tick.
## Sequence Diagram
@@ -19,6 +20,9 @@ sequenceDiagram
participant Client as UI / Operator API client
participant Pipeline as ASP.NET Pipeline
participant Handler as JwtBearerHandler
participant Resolver as IssuerSigningKeyResolver
participant Mgr as ConfigurationManager<JsonWebKeySet>
participant Admin as admin (JWKS endpoint)
participant Policy as Auth policy "FL"
participant Ctrl as Feature Controller
participant Errs as 06_http_conventions
@@ -26,17 +30,36 @@ sequenceDiagram
Client->>Pipeline: HTTP request + Authorization: Bearer <jwt>
Pipeline->>Errs: enter ErrorHandlingMiddleware
Errs->>Handler: hand off (anonymous endpoints skip this)
Handler->>Handler: parse token; verify HMAC-SHA256 signature using SymmetricSecurityKey(UTF-8(JWT_SECRET))
alt Signature invalid OR token expired (ClockSkew = 1 minute)
Handler-->>Client: 401 Unauthorized
else Valid token
Handler->>Handler: build ClaimsPrincipal; skip iss/aud validation
Handler->>Policy: evaluate policy "FL" (requires permissions claim == "FL")
alt Claim missing or != "FL"
Policy-->>Client: 403 Forbidden
else permissions=FL
Policy-->>Ctrl: forward to controller action
Ctrl-->>Client: business response
Handler->>Handler: parse token header — check alg ∈ ValidAlgorithms (EcdsaSha256)
alt alg not in pin list
Handler-->>Client: 401 Unauthorized (algorithm rejected)
else alg OK
Handler->>Resolver: resolve signing key for kid
Resolver->>Mgr: GetConfigurationAsync(...).GetAwaiter().GetResult()
alt JWKS not cached yet
Mgr->>Admin: GET /.well-known/jwks.json (HTTPS, RequireHttps=true)
Admin-->>Mgr: JsonWebKeySet
Mgr->>Mgr: cache JWKS, schedule next refresh
else JWKS cached
Mgr-->>Resolver: cached JsonWebKeySet
end
Resolver-->>Handler: signing keys matching kid (or all keys if kid empty)
Handler->>Handler: verify ECDSA-SHA256 signature
alt Signature invalid
Handler-->>Client: 401 Unauthorized
else Signature valid
Handler->>Handler: validate iss == JWT_ISSUER, aud == JWT_AUDIENCE, exp (ClockSkew = 30s)
alt iss/aud mismatch OR token expired
Handler-->>Client: 401 Unauthorized
else Claims OK
Handler->>Policy: evaluate policy "FL" (requires permissions claim == "FL")
alt Claim missing or != "FL"
Policy-->>Client: 403 Forbidden
else permissions=FL
Policy-->>Ctrl: forward to controller action
Ctrl-->>Client: business response
end
end
end
end
```
@@ -49,11 +72,20 @@ flowchart TD
AnonEP -->|no| Forward([Forward to controller])
AnonEP -->|yes| Header{Authorization: Bearer present?}
Header -->|no| Unauth1([401 Unauthorized])
Header -->|yes| Sig{HMAC-SHA256 signature valid?}
Header -->|yes| AlgPin{alg in ValidAlgorithms — EcdsaSha256?}
AlgPin -->|no| UnauthAlg([401 Unauthorized — algorithm rejected])
AlgPin -->|yes| Cache{JWKS cached?}
Cache -->|no| Fetch[HTTPS GET JWT_JWKS_URL → cache]
Cache -->|yes| Sig{ECDSA-SHA256 signature valid for kid?}
Fetch --> Sig
Sig -->|no| Unauth2([401 Unauthorized])
Sig -->|yes| Life{Lifetime valid? ClockSkew=1min}
Sig -->|yes| Iss{iss == JWT_ISSUER?}
Iss -->|no| UnauthIss([401 Unauthorized — issuer mismatch])
Iss -->|yes| Aud{aud == JWT_AUDIENCE?}
Aud -->|no| UnauthAud([401 Unauthorized — audience mismatch])
Aud -->|yes| Life{Lifetime valid? ClockSkew=30s}
Life -->|no| Unauth3([401 Unauthorized — expired])
Life -->|yes| BuildPrincipal[Build ClaimsPrincipal — skip iss/aud]
Life -->|yes| BuildPrincipal[Build ClaimsPrincipal]
BuildPrincipal --> Policy{permissions claim == FL?}
Policy -->|no| Forbid([403 Forbidden])
Policy -->|yes| Forward
@@ -64,10 +96,13 @@ flowchart TD
| Step | From | To | Data | Format |
|------|------|----|------|--------|
| 1 | Client | Pipeline | `Authorization: Bearer <jwt>` header | HTTP header |
| 2 | `JwtBearerHandler` | (in-process) | parsed JWT (header, payload, signature) | JSON Web Token |
| 3 | `JwtBearerHandler` | (in-process) | `ClaimsPrincipal` | .NET principal object |
| 4 | Authorization policy evaluator | Controller | "policy satisfied" / `403` | flag |
| 5 | `JwtBearerHandler` | Client (only on failure) | `401` / `403` | HTTP status (no body) |
| 2 | `JwtBearerHandler` | `ConfigurationManager` | request for cached `JsonWebKeySet` (or refresh) | in-process |
| 3 | `HttpDocumentRetriever` (on cold cache) | `admin` | `GET /.well-known/jwks.json` over HTTPS | HTTP |
| 4 | `admin` | `HttpDocumentRetriever` | JWKS JSON document | application/json |
| 5 | `JwtBearerHandler` | (in-process) | parsed JWT (header, payload, signature) | JSON Web Token |
| 6 | `JwtBearerHandler` | (in-process) | `ClaimsPrincipal` (with `iss`, `aud`, `permissions`, …) | .NET principal object |
| 7 | Authorization policy evaluator | Controller | "policy satisfied" / `403` | flag |
| 8 | `JwtBearerHandler` | Client (only on failure) | `401` / `403` | HTTP status (no body) |
## Error Scenarios
@@ -75,19 +110,25 @@ flowchart TD
|-------|-------|-----------|----------|
| Missing `Authorization` header on `[Authorize]` route | `JwtBearerHandler` | Header absent | `401`. Client must obtain a token from `admin` |
| Malformed JWT | `JwtBearerHandler` | Token parse failure | `401` |
| Signature mismatch (wrong / rotated `JWT_SECRET`) | `JwtBearerHandler` | HMAC verify fails | `401`. Suite-wide secret rotation is coordinated re-deploy of every backend that shares the secret + UI re-login |
| Expired token | `JwtBearerHandler` | `ValidateLifetime = true` (`ClockSkew = 1 min`) | `401`. Tighter than .NET's 5-min default — caller may experience earlier expiration than expected |
| Token header `alg` not in `[EcdsaSha256]` (e.g. forged `alg: HS256`) | `JwtBearerHandler` | Algorithm pin check | `401`. Pin defends against the HS256-confusion attack — see `05_identity` Caveats #6 |
| Signature mismatch (wrong key, key not yet published, key rotated out) | `JwtBearerHandler` | ECDSA verify fails | `401`. Recovery: ensure `admin` published the corresponding `kid` in JWKS; on rotation the cache picks up the new keys at the next refresh tick |
| Signing `kid` not in cached JWKS | `IssuerSigningKeyResolver` | No matching key in current cache | `401`. The manager refreshes on its default schedule; a new `kid` becomes available there |
| `iss` claim ≠ `JWT_ISSUER` | `JwtBearerHandler` | `ValidateIssuer = true` | `401`. Tokens issued by a different `iss` (e.g. another suite environment) are rejected |
| `aud` claim ≠ `JWT_AUDIENCE` | `JwtBearerHandler` | `ValidateAudience = true` | `401`. Tokens minted for a different audience (e.g. `admin` itself, or another backend) are rejected |
| Expired token | `JwtBearerHandler` | `ValidateLifetime = true` (`ClockSkew = 30s`) | `401`. Tight 30-second skew — caller may experience earlier expiration than under the .NET default of 5 minutes (or the prior 1-minute setting) |
| `permissions` claim missing or wrong value | Policy `"FL"` evaluator | claim lookup | `403` |
| Token signed with the well-known dev fallback secret | (silent acceptance) | None | **Security risk in production**. ADR-005 carry-forward; suite-tracked under CMMC L2 row 3 |
| Token from a third-party that knows `JWT_SECRET` | (silent acceptance) | None | **Trust model is shared-secret intra-suite**. Any third-party with the secret can mint accepted tokens. Out of this Epic's scope; suite-wide concern |
| `admin` unreachable on first JWKS fetch | `HttpDocumentRetriever` | `HttpRequestException` propagated through `IssuerSigningKeyResolver` | First protected request fails 500 (handler exception → `06_http_conventions` global handler). Subsequent requests retry on the next refresh tick. **Operationally**: ensure `admin` is reachable from every edge device that authenticates against it |
| `JWT_JWKS_URL` is plain HTTP | Startup (`HttpDocumentRetriever { RequireHttps = true }`) | URL scheme check at retrieve time | Service fails to validate any request; symptom is `InvalidOperationException` on JWKS fetch. **Fix**: set `JWT_JWKS_URL` to an `https://` URL |
## Performance Expectations
| Metric | Target | Notes |
|--------|--------|-------|
| Validation latency | sub-millisecond typical | Pure HMAC + claim lookup; no I/O, no network call |
| Throughput | bounded by request throughput | No back-pressure; no token cache (no DB / network round-trip to cache) |
| Validation latency (warm cache) | sub-millisecond typical | Pure ECDSA verify + claim lookup; no I/O |
| Validation latency (cold cache, first request) | one-time JWKS fetch cost (single-digit ms on local LAN) | Synchronous `GetAwaiter().GetResult()` blocks the worker thread until the fetch returns |
| Throughput | bounded by request throughput | No back-pressure; the cached JWKS handles all subsequent requests until refresh |
| JWKS refresh frequency | `ConfigurationManager` default (5 minutes minimum) | Matches admin's `Cache-Control: public, max-age=3600` so a forced refresh always sees fresh content |
## Notes on `iss` / `aud` validation (suite-tracked)
## Notes on key rotation
`ValidateIssuer = false`, `ValidateAudience = false` — consistent with the shared-secret intra-suite model. The CMMC L2 scorecard (`../../../suite/_docs/05_security/cmmc_l2_scorecard.md` row 3) flags this as a finding. The remediation will copy the `satellite-provider` pattern across `annotations` and `missions` (suite work, AZ-487 / AZ-494). It is **NOT** in this Epic's scope and will not change as part of the rename refactor.
Unlike the legacy shared-secret model, JWKS rotation does **NOT** require a coordinated redeploy of every consumer. When `admin` rotates keys it publishes the new key alongside the old `kid` (or with a new `kid`). The next refresh tick on every consumer's `ConfigurationManager` picks up the new public key. Old tokens signed under the previous `kid` remain valid until expiry as long as the old `kid` is still published. This is a major operational improvement over the previous "rotate `JWT_SECRET`, re-deploy every backend, force every user to re-login" sequence.