Files
missions/_docs/02_document/diagrams/flows/flow_jwt_validation.md
T
Oleksandr Bezdieniezhnykh 78dea8ebab
ci/woodpecker/push/build-arm Pipeline was successful
chore: update configuration and Docker setup for JWT and test results
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.
2026-05-15 03:23:23 +03:00

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 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 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_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

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.