mirror of
https://github.com/azaion/missions.git
synced 2026-06-21 08:01:07 +00:00
78dea8ebab
ci/woodpecker/push/build-arm Pipeline was successful
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.
121 lines
11 KiB
Markdown
121 lines
11 KiB
Markdown
# 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<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.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.
|