Files
missions/_docs/02_document/modules/auth.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

11 KiB
Raw Blame History

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

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<JsonWebKeySet> wired with:

    • The resolved jwksUrl.
    • A custom JwksRetriever : IConfigurationRetriever<JsonWebKeySet> (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 = <resolved JWT_ISSUER>
    ValidateAudience true ValidAudience = <resolved JWT_AUDIENCE>
    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", <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 to 10.0.5)
  • Microsoft.IdentityModel.Protocols (ConfigurationManager<T>, HttpDocumentRetriever, IConfigurationRetriever<T>, 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.csbuilder.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 57 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 startIssuerSigningKeyResolver 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 implementationMicrosoft.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.