Files
vigilcare-clinical/docs/guides/15-api-key-authentication.md
T
2026-06-25 00:25:31 +08:00

279 lines
12 KiB
Markdown

# Guide 15: API Key Authentication for Machine-to-Machine
## What is API Key Authentication?
Not every system that calls your API has a human user who can type a username and password. Some callers are machines — a ward gateway device reporting vital signs, a FHIR integration engine sending patient data from the hospital's EHR (Electronic Health Record). These systems need to authenticate without a login form.
An **API key** is a pre-shared secret — a long random string that both the client and server know. The client sends it in an HTTP header, and the server checks if it matches. Think of it like a password, but for machines instead of humans.
```
Client (ward gateway) Server (API)
┌─────────────────────┐ ┌──────────────┐
│ Sends request with: │ │ Checks: │
│ X-Api-Key: dev-gw.. │ ─────────────────────► │ Does the key │
│ X-Gateway-Id: 222.. │ │ match config?│
└─────────────────────┘ └──────────────┘
```
**Why not use JWT for machines too?** You could, but JWT adds complexity that machines don't need. JWT involves a login step (exchanging credentials for a token), token expiration, and token refresh. API keys are simpler — one secret, no expiration logic, no login endpoint. The tradeoff: API keys have no built-in expiration, so key rotation must be handled manually.
---
## Two API Key Systems in This Project
This project has two independent API key systems for different purposes:
| System | Header | Who Uses It | What It Protects |
|--------|--------|-------------|-----------------|
| Gateway API key | `X-Api-Key` + `X-Gateway-Id` | Ward gateway devices | Gateway-specific endpoints (heartbeat, sync upload) |
| FHIR API key | `X-Api-Key` | Hospital integration engines (like Mirth Connect) | FHIR R4 endpoints (`/fhir/R4/*`) |
Both use the same header name (`X-Api-Key`) but are handled by different authentication components and configured with different keys.
---
## System 1: Gateway API Key Authentication
### The Authentication Handler
When a ward gateway sends a request with `X-Api-Key`, the `GatewayApiKeyAuthenticationHandler` validates it:
```csharp
public sealed class GatewayApiKeyAuthenticationHandler
: AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "GatewayApiKey";
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
// 1. Check if the request has an X-Api-Key header
if (!Request.Headers.TryGetValue("X-Api-Key", out var suppliedHeader))
return Task.FromResult(AuthenticateResult.NoResult());
// NoResult means "I can't handle this — let another scheme try"
// 2. Load the configured key from appsettings
var configured = _config["ApiKey:Gateway"];
if (string.IsNullOrEmpty(configured))
return Task.FromResult(AuthenticateResult.Fail(
"Gateway API key not configured."));
// 3. Compare using constant-time comparison
if (!FixedTimeEquals(suppliedHeader.ToString(), configured))
return Task.FromResult(AuthenticateResult.Fail("Invalid API key."));
// 4. Create claims for the authenticated gateway
var claims = new List<Claim> { new("client_type", "gateway") };
if (Request.Headers.TryGetValue("X-Gateway-Id", out var gatewayIdHeader)
&& Guid.TryParse(gatewayIdHeader.ToString(), out _))
claims.Add(new Claim("gateway_id", gatewayIdHeader.ToString()!));
// 5. Return a successful authentication result
var identity = new ClaimsIdentity(claims, SchemeName);
var ticket = new AuthenticationTicket(
new ClaimsPrincipal(identity), SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
```
### Constant-Time Comparison
```csharp
private static bool FixedTimeEquals(string supplied, string configured)
{
var suppliedBytes = Encoding.UTF8.GetBytes(supplied);
var configuredBytes = Encoding.UTF8.GetBytes(configured);
return CryptographicOperations.FixedTimeEquals(suppliedBytes, configuredBytes);
}
```
**Why not just use `==` to compare strings?** Regular string comparison (`==`) is vulnerable to **timing attacks**. When comparing two strings character by character, the comparison fails faster when the first character is wrong than when the last character is wrong. An attacker can measure this timing difference (even over a network) and deduce the key one character at a time.
`CryptographicOperations.FixedTimeEquals` always takes the same amount of time regardless of where the mismatch occurs. It compares every byte even after finding a difference, so the timing reveals nothing about which bytes matched.
This matters in practice: timing attacks have been demonstrated against real APIs over the public internet with as few as ~1000 requests per character.
### Registration
The gateway scheme is registered alongside JWT bearer as a secondary authentication scheme:
```csharp
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => { /* JWT config */ })
.AddScheme<AuthenticationSchemeOptions, GatewayApiKeyAuthenticationHandler>(
GatewayApiKeyAuthenticationHandler.SchemeName, null);
```
**How does ASP.NET Core choose which scheme to use?** JWT Bearer is the default scheme (first argument to `AddAuthentication`). For most requests, ASP.NET tries JWT first. The gateway handler returns `NoResult()` when there's no `X-Api-Key` header, signaling "this isn't my request." For gateway endpoints that specifically require the `GatewayApiKey` scheme, controllers can specify:
```csharp
[Authorize(AuthenticationSchemes = GatewayApiKeyAuthenticationHandler.SchemeName)]
```
### Configuration
```json
{
"ApiKey": {
"Gateway": "dev-gateway-key-change-in-production"
}
}
```
---
## System 2: FHIR API Key (Middleware-Based)
FHIR endpoints accept either a JWT token OR an API key. This is implemented as middleware (not an authentication handler) because the dual-auth logic needs to run before the standard authentication pipeline:
```csharp
public class FhirApiKeyOrJwtMiddleware
{
public async Task InvokeAsync(HttpContext context)
{
// Only applies to /fhir/* paths
if (!context.Request.Path.StartsWithSegments("/fhir"))
{
await _next(context);
return;
}
// /fhir/R4/metadata is always public (FHIR standard)
if (context.Request.Path.StartsWithSegments("/fhir/R4/metadata"))
{
await _next(context);
return;
}
// If already authenticated via JWT, let it through
if (context.User.Identity?.IsAuthenticated == true)
{
await _next(context);
return;
}
// Try API key authentication
var configuredKeys = GetConfiguredKeys();
if (context.Request.Headers.TryGetValue("X-Api-Key", out var suppliedKey)
&& MatchesAnyKey(suppliedKey!, configuredKeys))
{
// Set up an Integration user identity
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, "44444444-..."),
new Claim(ClaimTypes.Name, "integration.mirth"),
new Claim("display_name", "Mirth Connect"),
new Claim("clinical_role", ClinicalRole.Integration.ToDbString()),
};
context.User = new ClaimsPrincipal(
new ClaimsIdentity(claims, "FhirApiKey"));
await _next(context);
return;
}
// X-Api-Key was provided but didn't match — return 401
if (context.Request.Headers.ContainsKey("X-Api-Key"))
{
context.Response.StatusCode = 401;
// Return FHIR OperationOutcome error format
return;
}
// No API key — fall through to normal JWT auth
await _next(context);
}
}
```
**Why set `clinical_role` to `Integration`?** When the API key authenticates successfully, the middleware creates a fake user identity with the `Integration` role. This means the RBAC system (Guide 14) works exactly the same way — the FHIR integration user has `Integration` permissions (can write patients, encounters, observations, but can't acknowledge alerts or modify thresholds).
### Key Rotation Support
The FHIR system supports multiple active keys simultaneously for zero-downtime rotation:
```csharp
public class FhirOptions
{
public string? ApiKey { get; set; } // single key (convenience)
public string[] ApiKeys { get; set; } = []; // multiple keys for rotation
}
```
```json
{
"Fhir": {
"ApiKey": "current-key-abc",
"ApiKeys": ["current-key-abc", "new-key-xyz"]
}
}
```
**How does key rotation work?**
1. Add the new key to `ApiKeys` alongside the old key → deploy
2. Update the integration system to use the new key
3. Remove the old key from `ApiKeys` → deploy
During step 1-2, both keys are valid. The integration system experiences zero downtime.
The key matching checks all configured keys using constant-time comparison:
```csharp
private static bool MatchesAnyKey(string supplied, string[] configuredKeys)
{
var suppliedBytes = Encoding.UTF8.GetBytes(supplied);
var matched = false;
foreach (var configured in configuredKeys)
{
var configuredBytes = Encoding.UTF8.GetBytes(configured);
if (CryptographicOperations.FixedTimeEquals(suppliedBytes, configuredBytes))
matched = true;
// Don't return early — check all keys to prevent timing leaks
}
return matched;
}
```
Note that it checks ALL keys even after finding a match. If it returned immediately on the first match, an attacker could determine how many keys are configured by measuring response time.
---
## How the Two Systems Interact
The middleware pipeline processes requests in order:
```csharp
app.UseMiddleware<CorrelationIdMiddleware>(); // 1. Add correlation ID
app.UseMiddleware<FhirApiKeyOrJwtMiddleware>(); // 2. FHIR API key (sets User if matched)
app.UseMiddleware<ExceptionHandlerMiddleware>(); // 3. Error handling
app.UseAuthentication(); // 4. JWT / GatewayApiKey handlers
app.UseAuthorization(); // 5. Permission checks
```
For a FHIR request with an API key:
- Step 2 matches the key and sets `context.User` with Integration claims
- Step 4 sees that `User` is already authenticated and skips JWT validation
- Step 5 checks the Integration role's permissions normally
For a gateway request with an API key:
- Step 2 doesn't match (`/api/gateways/...` doesn't start with `/fhir`)
- Step 4 runs the `GatewayApiKeyAuthenticationHandler`, which validates the key
- Step 5 checks authorization normally
For a dashboard request with a JWT:
- Step 2 doesn't match (no API key header)
- Step 4 validates the JWT token
- Step 5 checks the user's role permissions
---
## Key Takeaways
- **API keys are for machines, JWTs are for humans** — API keys are simpler (no login step, no expiration logic) but require manual rotation
- **Always use constant-time comparison for secrets** — `CryptographicOperations.FixedTimeEquals` prevents timing attacks that could leak the key character by character
- **Dual auth (key OR token) gives flexibility** — FHIR endpoints accept either, so both integration engines (API key) and admin users (JWT) can access them
- **Key rotation requires supporting multiple keys simultaneously** — add the new key first, migrate clients, then remove the old key
- **API key authentication creates a claims identity** — the `Integration` role feeds into the same RBAC system as JWT-authenticated users, so permissions are managed in one place
- **Return `NoResult()` for unrecognized requests** — this tells ASP.NET "try the next authentication scheme" rather than failing immediately, enabling multiple schemes to coexist