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.
11 KiB
Module: Azaion.Missions.Auth
Files (1): Auth/JwtExtensions.cs
NOTE (forward-looking): this module's source paths and namespace will become
Azaion.Missions.*after theflights -> missionsrename ticket lands (Jira AZ-EPIC, child B5 / B7). Today the file still saysAzaion.Flights. The behavior described below already matches the post-rename intent: only theFLpolicy remains, theGPSpolicy 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
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
-
Resolve three required values via
ConfigurationResolver.ResolveRequiredOrThrow:JWT_ISSUER/Jwt:Issuer— expectedissclaim value.JWT_AUDIENCE/Jwt:Audience— expectedaudclaim value.JWT_JWKS_URL/Jwt:JwksUrl— HTTPS URL ofadmin's JWKS document.
If any is missing or whitespace-only, the call throws
InvalidOperationExceptionat startup. There is no dev fallback for any of these values. -
Build a
ConfigurationManager<JsonWebKeySet>wired with:- The resolved
jwksUrl. - A custom
JwksRetriever : IConfigurationRetriever<JsonWebKeySet>(private nested class) that delegates the HTTP fetch to the suppliedIDocumentRetrieverand constructs aJsonWebKeySetfrom 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
ConfigurationManagerdefault schedule. This schedule matches admin'sCache-Control: public, max-age=3600on/.well-known/jwks.json(see../components/05_identity/description.mdfor the discovery rationale). The custom retriever exists becauseMicrosoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfigurationRetrievertargets the full OIDC discovery document, whichadmindoes not expose; only the JWKS endpoint is published. - The resolved
-
Register
JwtBearerauthentication with the followingTokenValidationParameters:Parameter Value Notes ValidateIssuertrueValidIssuer = <resolved JWT_ISSUER>ValidateAudiencetrueValidAudience = <resolved JWT_AUDIENCE>ValidateLifetimetrueValidateIssuerSigningKeytrueValidAlgorithms[SecurityAlgorithms.EcdsaSha256]Pinned — see Security §1 RequireSignedTokenstrueRequireExpirationTimetrueClockSkewTimeSpan.FromSeconds(30)Tighter than .NET default (5 minutes) IssuerSigningKeyResolverDelegate that fetches JsonWebKeySetvia the cachedConfigurationManagerand returns the subset whosekidmatches the token header (or all keys whenkidis 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 -
Register authorization policies via
AddAuthorizationBuilder:"FL"— requires apermissionsclaim with value"FL"."GPS"— requires apermissionsclaim with value"GPS". Removed after Jira B7 lands (the policy still exists today becauseControllers/FlightsController.csuses it for the GPS-Denied routes that B7 also removes).
RequireClaim("permissions", <code>) 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 to10.0.5)Microsoft.IdentityModel.Protocols(ConfigurationManager<T>,HttpDocumentRetriever,IConfigurationRetriever<T>,IDocumentRetriever)Microsoft.IdentityModel.Tokens(JsonWebKeySet,TokenValidationParameters,SecurityAlgorithms)Azaion.Flights.Infrastructure.ConfigurationResolver(internal — seemodules/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
adminfor JWKS retrieval. Required at startup (the first protected request blocks on this fetch).HttpDocumentRetriever.RequireHttps = truerejects non-HTTPS URLs at configuration time. Ifadminis unreachable at the time of the first JWKS fetch, the first request fails with a 500 from theIssuerSigningKeyResolverdelegate; the manager retries on the default refresh interval.
Security
- 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 withalg: HS256using 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'salgclaim. - 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 toadmin. - Issuer + audience binding:
ValidateIssuer = trueandValidateAudience = trueare 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. - Fail-fast on missing config:
ConfigurationResolver.ResolveRequiredOrThrowthrowsInvalidOperationExceptionat startup if any ofJWT_ISSUER/JWT_AUDIENCE/JWT_JWKS_URLis missing or whitespace-only. There is no dev fallback. A production deploy without these values cannot silently boot. - 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. - JWKS rotation model:
adminrotates by publishing a newkidin the JWKS; tokens signed under the previouskidremain valid until they expire. Because theIssuerSigningKeyResolverreturns all keys when the token header has nokidand 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
- Single permission (
FL) gates the whole mission API. All routes in01_vehicle_catalogand02_mission_planningcarry[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. - Synchronous JWKS fetch on the first request after cold start —
IssuerSigningKeyResolvercallsGetConfigurationAsync(...).GetAwaiter().GetResult(). This blocks the worker thread until the JWKS document is fetched and parsed. On the local LAN this is single-digit ms; ifadminis slow or unreachable, the first request takes the timeout hit. Subsequent requests use the cached keys without blocking. - No authentication scheme name override — uses
JwtBearerDefaults.AuthenticationScheme("Bearer"). Consistent. - No claim type for "user id" is consumed — only the
permissionsclaim 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 takeHttpContext.User). When02_mission_planningadds attribution to actions like waypoint-set / mission-rename, this becomes a blocker. JwksRetrieveris a hand-rolled minimal implementation —Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfigurationRetrieveris the stock retriever but it pulls the full OIDC discovery document;adminonly exposes JWKS. The private nested class is ~5 lines and is the smallest correct adapter. Ifadminever publishes a full OIDC discovery document, swapping to the stock retriever is a one-line change.