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.
9.2 KiB
Flow F5 — JWT bearer validation
Cross-cutting flow that runs on every
[Authorize]request. ECDSA-SHA256 asymmetric validation against public keys cached fromadmin's JWKS endpoint.adminis contacted once at startup (and on JWKS refresh) for the JWKS document; subsequent request-path validation is local and does not calladmin.
Description
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_ISSUER,JWT_AUDIENCE,JWT_JWKS_URLare all resolved at startup viaConfigurationResolver.ResolveRequiredOrThrow. Any missing value aborts startup before the host is built.AddJwtAuth(builder.Configuration)was called duringProgram.csstartup (F6); this also wired theConfigurationManager<JsonWebKeySet>against the resolved JWKS URL.- For the first protected request after process start, the cached JWKS is empty; the
IssuerSigningKeyResolversynchronously fetches it fromadmin. After that fetch, subsequent requests use the cached keys until the manager's next refresh tick.
Sequence Diagram
sequenceDiagram
autonumber
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
Client->>Pipeline: HTTP request + Authorization: Bearer <jwt>
Pipeline->>Errs: enter ErrorHandlingMiddleware
Errs->>Handler: hand off (anonymous endpoints skip this)
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
Flowchart
flowchart TD
Start([Incoming request]) --> AnonEP{Endpoint requires auth?}
AnonEP -->|no| Forward([Forward to controller])
AnonEP -->|yes| Header{Authorization: Bearer present?}
Header -->|no| Unauth1([401 Unauthorized])
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| 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]
BuildPrincipal --> Policy{permissions claim == FL?}
Policy -->|no| Forbid([403 Forbidden])
Policy -->|yes| Forward
Data Flow
| Step | From | To | Data | Format |
|---|---|---|---|---|
| 1 | Client | Pipeline | Authorization: Bearer <jwt> header |
HTTP header |
| 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
| Error | Where | Detection | Recovery |
|---|---|---|---|
Missing Authorization header on [Authorize] route |
JwtBearerHandler |
Header absent | 401. Client must obtain a token from admin |
| Malformed JWT | JwtBearerHandler |
Token parse failure | 401 |
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 |
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 (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 key rotation
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.