# Module: `Azaion.Missions.Auth` **Files (1)**: `Auth/JwtExtensions.cs` > **NOTE (forward-looking)**: this module's source paths and namespace will become `Azaion.Missions.*` after the `flights -> missions` rename ticket lands (Jira AZ-EPIC, child B5 / B7). Today the file still says `Azaion.Flights`. The behavior described below already matches the post-rename intent: only the `FL` policy remains, the `GPS` policy is removed (per B7). ## Purpose Single static extension (`AddJwtAuth`) that registers JWT bearer authentication and the named authorization policy `FL` used by controllers. Token signatures are validated against **ECDSA P-256 public keys** retrieved from the central `admin` service's JWKS endpoint at startup and refreshed on the .NET `ConfigurationManager` default schedule. ## Public Interface ```csharp public static class JwtExtensions { // Env / config-key contract (string constants — referenced by tests + Program.cs). public const string JwtIssuerEnvVar = "JWT_ISSUER"; public const string JwtIssuerConfigKey = "Jwt:Issuer"; public const string JwtAudienceEnvVar = "JWT_AUDIENCE"; public const string JwtAudienceConfigKey = "Jwt:Audience"; public const string JwtJwksUrlEnvVar = "JWT_JWKS_URL"; public const string JwtJwksUrlConfigKey = "Jwt:JwksUrl"; public static IServiceCollection AddJwtAuth(this IServiceCollection services, IConfiguration configuration); } ``` `AddJwtAuth` takes `IConfiguration` — there is no string-secret parameter. All three required values are resolved internally via `ConfigurationResolver.ResolveRequiredOrThrow` (env var first, then config key, else throw at startup). See `modules/program.md` for the resolver contract. ## Internal Logic 1. **Resolve three required values** via `ConfigurationResolver.ResolveRequiredOrThrow`: - `JWT_ISSUER` / `Jwt:Issuer` — expected `iss` claim value. - `JWT_AUDIENCE` / `Jwt:Audience` — expected `aud` claim value. - `JWT_JWKS_URL` / `Jwt:JwksUrl` — HTTPS URL of `admin`'s JWKS document. If any is missing or whitespace-only, the call throws `InvalidOperationException` at startup. There is **no dev fallback** for any of these values. 2. **Build a `ConfigurationManager`** wired with: - The resolved `jwksUrl`. - A custom `JwksRetriever : IConfigurationRetriever` (private nested class) that delegates the HTTP fetch to the supplied `IDocumentRetriever` and constructs a `JsonWebKeySet` from the returned JSON body. - An `HttpDocumentRetriever { RequireHttps = true }` — plain HTTP JWKS URLs are rejected. The manager caches the JWKS in memory and refreshes on the .NET `ConfigurationManager` default schedule. This schedule matches admin's `Cache-Control: public, max-age=3600` on `/.well-known/jwks.json` (see `../components/05_identity/description.md` for the discovery rationale). The custom retriever exists because `Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfigurationRetriever` targets the full OIDC discovery document, which `admin` does not expose; only the JWKS endpoint is published. 3. **Register `JwtBearer` authentication** with the following `TokenValidationParameters`: | Parameter | Value | Notes | |-----------|-------|-------| | `ValidateIssuer` | `true` | `ValidIssuer = ` | | `ValidateAudience` | `true` | `ValidAudience = ` | | `ValidateLifetime` | `true` | | | `ValidateIssuerSigningKey` | `true` | | | `ValidAlgorithms` | `[SecurityAlgorithms.EcdsaSha256]` | Pinned — see Security §1 | | `RequireSignedTokens` | `true` | | | `RequireExpirationTime` | `true` | | | `ClockSkew` | `TimeSpan.FromSeconds(30)` | Tighter than .NET default (5 minutes) | | `IssuerSigningKeyResolver` | Delegate that fetches `JsonWebKeySet` via the cached `ConfigurationManager` and returns the subset whose `kid` matches the token header (or all keys when `kid` is empty) | Synchronous `GetAwaiter().GetResult()` over the async fetch — first call triggers the JWKS HTTP fetch and blocks until it completes; subsequent calls hit the cache | 4. **Register authorization policies** via `AddAuthorizationBuilder`: - `"FL"` — requires a `permissions` claim with value `"FL"`. - `"GPS"` — requires a `permissions` claim with value `"GPS"`. **Removed after Jira B7 lands** (the policy still exists today because `Controllers/FlightsController.cs` uses it for the GPS-Denied routes that B7 also removes). `RequireClaim("permissions", )` matches on a claim named `"permissions"` whose value equals the code. Multi-permission tokens typically have multiple `permissions` claims, one per permission. ## Suite-wide JWT pattern This service consumes JWTs minted by the remote `admin` service against the central user PostgreSQL (per `../../suite/_docs/00_top_level_architecture.md` and `../../suite/_docs/10_auth.md`). Every `.NET` service in the suite — `admin`, `annotations`, `missions` (this one), `satellite-provider` — uses the **same ECDSA public-key model**: `admin` signs with the private key; every consumer fetches the public JWKS from `admin` and validates locally. The user logs in once at the UI; the resulting bearer token is reusable across every service. Unlike a pure "validate locally, never call back" model, this service **does** contact `admin` once at startup (and on JWKS refresh) to fetch the JWKS document. Once cached, request-path validation is purely cryptographic and does not call `admin`. The first request after a cold start blocks on the JWKS fetch (single-digit ms typical on the local LAN); subsequent requests use the cached keys. ## Dependencies - `Microsoft.AspNetCore.Authentication.JwtBearer` (NuGet, pinned to `10.0.5`) - `Microsoft.IdentityModel.Protocols` (`ConfigurationManager`, `HttpDocumentRetriever`, `IConfigurationRetriever`, `IDocumentRetriever`) - `Microsoft.IdentityModel.Tokens` (`JsonWebKeySet`, `TokenValidationParameters`, `SecurityAlgorithms`) - `Azaion.Flights.Infrastructure.ConfigurationResolver` (internal — see `modules/program.md`) No internal dependencies on other domain modules. ## Consumers - `Program.cs` — `builder.Services.AddJwtAuth(builder.Configuration)` is called once at startup. - Controllers reference the policies indirectly via `[Authorize(Policy = "FL")]` and (until B7) `[Authorize(Policy = "GPS")]`. ## Configuration Reads three values via `ConfigurationResolver.ResolveRequiredOrThrow`: | Env var | Config key | Required? | Purpose | |---------|------------|-----------|---------| | `JWT_ISSUER` | `Jwt:Issuer` | **Yes** (throws at startup if missing) | Expected `iss` claim value | | `JWT_AUDIENCE` | `Jwt:Audience` | **Yes** (throws at startup if missing) | Expected `aud` claim value | | `JWT_JWKS_URL` | `Jwt:JwksUrl` | **Yes** (throws at startup if missing) | HTTPS URL of admin's JWKS endpoint (e.g. `https://admin.azaion/.well-known/jwks.json`) | Resolution order per value: `Environment.GetEnvironmentVariable(envVar)` → `IConfiguration[configKey]` → throw. No hardcoded fallback. No legacy `JWT_SECRET` is consulted. ## External Integrations - **Outbound HTTPS to `admin`** for JWKS retrieval. Required at startup (the first protected request blocks on this fetch). `HttpDocumentRetriever.RequireHttps = true` rejects non-HTTPS URLs at configuration time. If `admin` is unreachable at the time of the first JWKS fetch, the first request fails with a 500 from the `IssuerSigningKeyResolver` delegate; the manager retries on the default refresh interval. ## Security 1. **Algorithm pinning**: `ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256]`. Pinning prevents the classic "HS256 confusion" attack — without this, an attacker who learned the JWKS public key could forge a token with `alg: HS256` using the public key as the HMAC secret, and stock JWT bearer validation would accept it. The pin forces ECDSA-SHA256 regardless of the JWT header's `alg` claim. 2. **HTTPS-only JWKS**: `HttpDocumentRetriever { RequireHttps = true }`. A plain-HTTP JWKS URL is rejected at configuration time. MITM substitution of the public key requires breaking TLS to `admin`. 3. **Issuer + audience binding**: `ValidateIssuer = true` and `ValidateAudience = true` are enforced. Tokens minted by a different issuer or for a different audience are rejected even if the signature is valid. This was the AZ-487 / AZ-494 finding in the prior HS256 model; it is now structurally fixed in code. 4. **Fail-fast on missing config**: `ConfigurationResolver.ResolveRequiredOrThrow` throws `InvalidOperationException` at startup if any of `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` is missing or whitespace-only. There is **no dev fallback**. A production deploy without these values cannot silently boot. 5. **Tight clock skew**: 30 seconds (`TimeSpan.FromSeconds(30)`) — tighter than .NET's 5-minute default and tighter than the legacy 1-minute setting. Reduces the window during which a token rejected for clock drift is still cryptographically valid. 6. **JWKS rotation model**: `admin` rotates by publishing a new `kid` in the JWKS; tokens signed under the previous `kid` remain valid until they expire. Because the `IssuerSigningKeyResolver` returns all keys when the token header has no `kid` and the matching subset when it does, both old and new tokens validate during the overlap window. **No coordinated re-deploy is needed** when keys rotate — this is the major operational improvement over the legacy shared-secret model. ## Tests None present today; will be filled by the autodev BUILD pipeline (Steps 5–7 in the existing-code flow). Test-spec scope is in `_docs/02_document/tests/security-tests.md` (NFT-SEC-*). ## Notes / Smells 1. **Single permission (`FL`) gates the whole mission API.** All routes in `01_vehicle_catalog` and `02_mission_planning` carry `[Authorize(Policy = "FL")]`. There is no operator-vs-admin distinction at this layer; granular permissions are governed by the role → permission matrix in `../../suite/_docs/00_roles_permissions.md`. 2. **Synchronous JWKS fetch on the first request after cold start** — `IssuerSigningKeyResolver` calls `GetConfigurationAsync(...).GetAwaiter().GetResult()`. This blocks the worker thread until the JWKS document is fetched and parsed. On the local LAN this is single-digit ms; if `admin` is slow or unreachable, the first request takes the timeout hit. Subsequent requests use the cached keys without blocking. 3. **No authentication scheme name override** — uses `JwtBearerDefaults.AuthenticationScheme` ("Bearer"). Consistent. 4. **No claim type for "user id" is consumed** — only the `permissions` claim is checked. Whatever subject identity the issuer puts in the token is ignored at the policy layer. Audit logs / business rules that need a per-user identifier currently have no per-call user binding (services don't take `HttpContext.User`). When `02_mission_planning` adds attribution to actions like waypoint-set / mission-rename, this becomes a blocker. 5. **`JwksRetriever` is a hand-rolled minimal implementation** — `Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfigurationRetriever` is the stock retriever but it pulls the full OIDC discovery document; `admin` only exposes JWKS. The private nested class is ~5 lines and is the smallest correct adapter. If `admin` ever publishes a full OIDC discovery document, swapping to the stock retriever is a one-line change.