Skip to Content
Deneva MCPComponentsTenant Middleware

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 uses req.tenantId to know which tenant is connecting.
  • /auth/:platform/callback is 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 carries tenantId. Don’t add auth to /callback — it would break the flow.
  • /health and /admin/* have their own access control (/admin/* checks x-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 handler

Constant-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

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.