Tenant authentication middleware
Source: src/security/tenant.middleware.ts
Resolves the tenant from the X-Api-Key header on every /mcp/* request and on /auth/:platform/start, writes one audit row per outcome, and integrates with the IP-block service for repeated-failure throttling.
What needs auth
function needsAuth(url: string): boolean {
return url.startsWith('/mcp') || /^\/auth\/[^/]+\/start(?:\?|$)/.test(url);
}/mcp/*— tools./auth/:platform/start— the OAuth start route usesreq.tenantIdto know which tenant is connecting./auth/:platform/callbackis intentionally NOT here. The browser comes back from Google with no way to attach an API key; the single-use state row consumed by the callback carriestenantId. Don’t add auth to /callback — it would break the flow./healthand/admin/*have their own access control (/admin/*checksx-admin-token); they don’t go through this middleware.
Plugin scope: must be fastify-plugin-wrapped
The export is fp(plugin), not the bare async function. Without fastify-plugin, Fastify encapsulates app.register(...)-mounted plugins in their own scope, and addHook('preHandler', ...) calls inside that scope only fire for routes registered inside the same scope. The /mcp route is mounted directly on the parent instance via mountMcp(app), so without the fp(...) wrapper the auth hook never runs and every request reaches the route handler with req.tenantId === undefined. The first 2026-05-07 Ubuntu smoke-test hit exactly this — see docs/phase-1-foundation.md §14 #6.
The same applies to tenantRateLimiterPlugin, which is also fp-wrapped for the identical reason.
Lifecycle on a single request
preHandler hook fires (only for needsAuth(url))
│
├─ isBlocked(req.ip) ? yes → 401, audit `auth.blocked_ip`
│
├─ X-Api-Key header missing or empty ?
│ → recordFailure(ip) (returns true if threshold tripped → audit `auth.blocked_ip`)
│ → audit `api_key.auth_failure` (reason: missing)
│ → 401
│
├─ Hash the key (HMAC-SHA256)
│
├─ DB lookup: keyHash AND not revoked AND expiresAt > now
│ │
│ ├─ no row found
│ │ → recordFailure(ip)
│ │ → audit `api_key.auth_failure` (reason: invalid)
│ │ → 401
│ │
│ └─ row found
│ → req.tenantId = row.tenantId
│ → req.apiKeyId = row.id
│ → fire-and-forget UPDATE lastUsedAt
│ → audit `api_key.auth_success`
│ → continue to next handlerConstant-time properties
Doing the DB lookup after hashing means missing-key and invalid-key paths take roughly similar time. A faster missing-header path is acceptable: we leak only “header presence”, not “this specific key is the right shape”.
Why fire-and-forget on lastUsedAt?
lastUsedAt is a metric, not a security control. Blocking the request on its UPDATE adds a write to every authenticated call for no benefit; if the UPDATE fails transiently, the next successful auth retries it.
Cross-references
- API key service —
hashApiKey(). - IP block —
isBlocked(),recordFailure(). - Audit log — every branch above writes one row.
Tests
The full E2E auth flow (200 / 401 / IP-block engagement) is exercised by tests/integration/auth.test.ts (Phase 1 §D4). The unit-level auth-key tests live in api-key.test.ts.