From 81dbceaf1ab9ed8276a56c2a5d01d094edcabe08 Mon Sep 17 00:00:00 2001 From: rajashish147 Date: Mon, 13 Apr 2026 01:26:01 +0530 Subject: [PATCH 1/5] feat(webhooks): enhance webhook functionality with test delivery and event compatibility --- docs/WEBHOOK_SIGNATURES.md | 2 + src/modules/attendance/attendance.service.ts | 21 +++ src/modules/webhooks/webhooks.controller.ts | 13 ++ src/modules/webhooks/webhooks.repository.ts | 82 +++++++++- src/modules/webhooks/webhooks.routes.ts | 142 ++++++++++++++++++ src/modules/webhooks/webhooks.schema.ts | 15 ++ src/modules/webhooks/webhooks.service.ts | 65 ++++++++ src/utils/event-bus.ts | 19 +++ src/workers/webhook-event.service.ts | 3 + src/workers/webhook.worker.ts | 9 +- .../admin/webhooks.integration.test.ts | 57 +++++++ 11 files changed, 423 insertions(+), 5 deletions(-) diff --git a/docs/WEBHOOK_SIGNATURES.md b/docs/WEBHOOK_SIGNATURES.md index 5161c9a..cf93853 100644 --- a/docs/WEBHOOK_SIGNATURES.md +++ b/docs/WEBHOOK_SIGNATURES.md @@ -9,7 +9,9 @@ Every outbound webhook request from FieldTrack includes security headers that al | Header | Example value | Purpose | |---|---|---| | `X-FieldTrack-Signature` | `sha256=a3f1c8...` | HMAC-SHA256 of the signing body (see below) | +| `X-Webhook-Signature` | `sha256=a3f1c8...` | Compatibility alias of `X-FieldTrack-Signature` | | `X-FieldTrack-Timestamp` | `1711618200` | Unix timestamp **in seconds** at delivery time | +| `X-Webhook-Timestamp` | `1711618200` | Compatibility alias of `X-FieldTrack-Timestamp` | | `X-FieldTrack-Event` | `employee.checked_in` | Logical event type for routing | | `X-FieldTrack-Delivery-Id` | `1b2f...-uuid` | Unique delivery attempt id for idempotency / replay dedupe | diff --git a/src/modules/attendance/attendance.service.ts b/src/modules/attendance/attendance.service.ts index 4a1f4ee..c7928fa 100644 --- a/src/modules/attendance/attendance.service.ts +++ b/src/modules/attendance/attendance.service.ts @@ -80,6 +80,16 @@ export const attendanceService = { }, }); + // Canonical webhook event name (kept alongside legacy alias for compatibility) + emitEvent("session.checkin", { + organization_id: request.organizationId, + data: { + employee_id: employeeId, + session_id: session.id, + checkin_at: session.checkin_at, + }, + }); + return session; }, @@ -135,6 +145,17 @@ export const attendanceService = { }, }); + // Canonical webhook event name (kept alongside legacy alias for compatibility) + emitEvent("session.checkout", { + organization_id: request.organizationId, + data: { + employee_id: employeeId, + session_id: closedSession.id, + checkin_at: closedSession.checkin_at, + checkout_at: closedSession.checkout_at ?? new Date().toISOString(), + }, + }); + try { await enqueueDistanceJob(closedSession.id); } catch (err: unknown) { diff --git a/src/modules/webhooks/webhooks.controller.ts b/src/modules/webhooks/webhooks.controller.ts index d1a01c3..ca7d61b 100644 --- a/src/modules/webhooks/webhooks.controller.ts +++ b/src/modules/webhooks/webhooks.controller.ts @@ -87,4 +87,17 @@ export const webhooksController = { handleError(error, request, reply, "Failed to retry delivery"); } }, + + async testWebhook( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, + ): Promise { + try { + const { id } = request.params; + const result = await webhooksService.testWebhook(request, id); + reply.status(202).send(ok(result)); + } catch (error) { + handleError(error, request, reply, "Failed to send test webhook"); + } + }, }; diff --git a/src/modules/webhooks/webhooks.repository.ts b/src/modules/webhooks/webhooks.repository.ts index 2ea8589..134cc76 100644 --- a/src/modules/webhooks/webhooks.repository.ts +++ b/src/modules/webhooks/webhooks.repository.ts @@ -21,10 +21,32 @@ import type { } from "./webhooks.schema.js"; const WEBHOOK_DELIVERY_COLUMNS = - "id, webhook_id, event_id, organization_id, status, attempt_count, response_status, response_body, last_attempt_at, next_retry_at, created_at"; + "id, webhook_id, event_id, event_type, organization_id, status, attempt_count, response_status, response_body, last_attempt_at, next_retry_at, created_at"; const WEBHOOK_DLQ_COLUMNS = "id, webhook_id, organization_id, event_id, event_type, payload, status, attempt_count, response_status, response_body, last_error, next_retry_at, last_attempt_at, created_at"; +type DeliveryRow = { + id: string; + webhook_id: string; + event_id: string; + event_type: string | null; + organization_id: string; + status: "pending" | "success" | "failed"; + attempt_count: number; + response_status: number | null; + response_body: string | null; + last_attempt_at: string | null; + next_retry_at: string | null; + created_at: string; +}; + +function mapDeliveryRow(row: DeliveryRow): WebhookDelivery { + return { + ...row, + response_code: row.response_status, + } as WebhookDelivery; +} + // ─── Webhook CRUD ───────────────────────────────────────────────────────────── export const webhooksRepository = { @@ -134,7 +156,8 @@ export const webhooksRepository = { const { data, error, count } = await q; if (error) throw new Error(`Failed to list deliveries: ${error.message}`); - return { data: (data ?? []) as WebhookDelivery[], total: count ?? 0 }; + const rows = (data ?? []) as DeliveryRow[]; + return { data: rows.map(mapDeliveryRow), total: count ?? 0 }; }, /** @@ -215,7 +238,8 @@ export const webhooksRepository = { .maybeSingle(); if (error) throw new Error(`Failed to fetch delivery: ${error.message}`); - return (data as WebhookDelivery | null) ?? null; + const row = (data as DeliveryRow | null) ?? null; + return row ? mapDeliveryRow(row) : null; }, /** @@ -249,6 +273,56 @@ export const webhooksRepository = { .single(); if (error) throw new Error(`Failed to reset delivery: ${error.message}`); - return data as WebhookDelivery; + return mapDeliveryRow(data as DeliveryRow); + }, + + /** + * Persist a synthetic webhook event payload used by POST /webhooks/:id/test. + */ + async createEvent( + request: FastifyRequest, + eventId: string, + eventType: string, + payload: Record, + ): Promise { + const { data, error } = await supabase + .from("webhook_events") + .insert({ + id: eventId, + organization_id: request.organizationId, + event_type: eventType, + payload, + }) + .select("id") + .single(); + + if (error) throw new Error(`Failed to create webhook event: ${error.message}`); + return data.id as string; + }, + + /** + * Create a pending delivery row for a specific webhook and event. + */ + async createDelivery( + request: FastifyRequest, + webhookId: string, + eventId: string, + eventType: string, + ): Promise { + const { data, error } = await supabase + .from("webhook_deliveries") + .insert({ + webhook_id: webhookId, + event_id: eventId, + event_type: eventType, + organization_id: request.organizationId, + status: "pending", + attempt_count: 0, + }) + .select(WEBHOOK_DELIVERY_COLUMNS) + .single(); + + if (error) throw new Error(`Failed to create webhook delivery: ${error.message}`); + return mapDeliveryRow(data as DeliveryRow); }, }; diff --git a/src/modules/webhooks/webhooks.routes.ts b/src/modules/webhooks/webhooks.routes.ts index 3257969..66e48b4 100644 --- a/src/modules/webhooks/webhooks.routes.ts +++ b/src/modules/webhooks/webhooks.routes.ts @@ -9,7 +9,10 @@ * DELETE /admin/webhooks/:id — remove webhook and all deliveries * * GET /admin/webhook-deliveries — list delivery attempts + * GET /admin/webhooks/logs — alias for delivery logs * POST /admin/webhook-deliveries/:id/retry — manually retry a failed delivery + * POST /admin/webhooks/logs/:id/retry — alias for log retry + * POST /admin/webhooks/:id/test — enqueue a synthetic test delivery */ import type { FastifyInstance } from "fastify"; @@ -23,6 +26,7 @@ import { deliveryListQuerySchema, webhookPublicSchema, webhookDeliverySchema, + webhookTestResponseSchema, } from "./webhooks.schema.js"; const webhookResponse = z.object({ @@ -56,6 +60,19 @@ export async function webhooksRoutes(app: FastifyInstance): Promise { webhooksController.list, ); + app.get( + "/webhooks", + { + schema: { + tags: ["admin", "webhooks"], + summary: "List registered webhooks (secrets omitted)", + response: { 200: webhookListResponse }, + }, + preValidation: [authenticate, requireRole("ADMIN")], + }, + webhooksController.list, + ); + app.post( "/admin/webhooks", { @@ -70,6 +87,20 @@ export async function webhooksRoutes(app: FastifyInstance): Promise { webhooksController.create, ); + app.post( + "/webhooks", + { + schema: { + tags: ["admin", "webhooks"], + summary: "Register a new webhook endpoint", + body: createWebhookBodySchema, + response: { 201: webhookResponse }, + }, + preValidation: [authenticate, requireRole("ADMIN")], + }, + webhooksController.create, + ); + app.patch<{ Params: { id: string } }>( "/admin/webhooks/:id", { @@ -85,6 +116,21 @@ export async function webhooksRoutes(app: FastifyInstance): Promise { webhooksController.update, ); + app.patch<{ Params: { id: string } }>( + "/webhooks/:id", + { + schema: { + tags: ["admin", "webhooks"], + summary: "Update webhook url, events, active state, or secret", + params: z.object({ id: z.string().uuid() }), + body: updateWebhookBodySchema, + response: { 200: webhookResponse }, + }, + preValidation: [authenticate, requireRole("ADMIN")], + }, + webhooksController.update, + ); + app.delete<{ Params: { id: string } }>( "/admin/webhooks/:id", { @@ -99,6 +145,20 @@ export async function webhooksRoutes(app: FastifyInstance): Promise { webhooksController.remove, ); + app.delete<{ Params: { id: string } }>( + "/webhooks/:id", + { + schema: { + tags: ["admin", "webhooks"], + summary: "Delete a webhook and all its delivery history", + params: z.object({ id: z.string().uuid() }), + response: { 204: z.null().describe("No content") }, + }, + preValidation: [authenticate, requireRole("ADMIN")], + }, + webhooksController.remove, + ); + // ─── Deliveries ──────────────────────────────────────────────────────────── app.get( @@ -114,6 +174,32 @@ export async function webhooksRoutes(app: FastifyInstance): Promise { webhooksController.listDeliveries, ); + app.get( + "/admin/webhooks/logs", + { + schema: { + tags: ["admin", "webhooks"], + summary: "List webhook delivery logs for this organization", + querystring: deliveryListQuerySchema, + }, + preValidation: [authenticate, requireRole("ADMIN")], + }, + webhooksController.listDeliveries, + ); + + app.get( + "/webhooks/logs", + { + schema: { + tags: ["admin", "webhooks"], + summary: "List webhook delivery logs for this organization", + querystring: deliveryListQuerySchema, + }, + preValidation: [authenticate, requireRole("ADMIN")], + }, + webhooksController.listDeliveries, + ); + app.post<{ Params: { id: string } }>( "/admin/webhook-deliveries/:id/retry", { @@ -127,4 +213,60 @@ export async function webhooksRoutes(app: FastifyInstance): Promise { }, webhooksController.retryDelivery, ); + + app.post<{ Params: { id: string } }>( + "/admin/webhooks/logs/:id/retry", + { + schema: { + tags: ["admin", "webhooks"], + summary: "Retry a webhook delivery log entry", + params: z.object({ id: z.string().uuid() }), + response: { 200: deliveryResponse }, + }, + preValidation: [authenticate, requireRole("ADMIN")], + }, + webhooksController.retryDelivery, + ); + + app.post<{ Params: { id: string } }>( + "/webhooks/logs/:id/retry", + { + schema: { + tags: ["admin", "webhooks"], + summary: "Retry a webhook delivery log entry", + params: z.object({ id: z.string().uuid() }), + response: { 200: deliveryResponse }, + }, + preValidation: [authenticate, requireRole("ADMIN")], + }, + webhooksController.retryDelivery, + ); + + app.post<{ Params: { id: string } }>( + "/admin/webhooks/:id/test", + { + schema: { + tags: ["admin", "webhooks"], + summary: "Send a synthetic test webhook to this endpoint", + params: z.object({ id: z.string().uuid() }), + response: { 202: webhookTestResponseSchema }, + }, + preValidation: [authenticate, requireRole("ADMIN")], + }, + webhooksController.testWebhook, + ); + + app.post<{ Params: { id: string } }>( + "/webhooks/:id/test", + { + schema: { + tags: ["admin", "webhooks"], + summary: "Send a synthetic test webhook to this endpoint", + params: z.object({ id: z.string().uuid() }), + response: { 202: webhookTestResponseSchema }, + }, + preValidation: [authenticate, requireRole("ADMIN")], + }, + webhooksController.testWebhook, + ); } diff --git a/src/modules/webhooks/webhooks.schema.ts b/src/modules/webhooks/webhooks.schema.ts index 18dc3c1..b722da0 100644 --- a/src/modules/webhooks/webhooks.schema.ts +++ b/src/modules/webhooks/webhooks.schema.ts @@ -10,6 +10,10 @@ import { z } from "zod"; // ─── Event type constants ──────────────────────────────────────────────────── export const WEBHOOK_EVENT_TYPES = [ + // Canonical event names (product-facing) + "session.checkin", + "session.checkout", + // Backward-compatible aliases used by existing integrations "employee.checked_in", "employee.checked_out", "expense.created", @@ -79,9 +83,11 @@ export const webhookDeliverySchema = z.object({ id: z.string().uuid(), webhook_id: z.string().uuid(), event_id: z.string().uuid(), + event_type: z.string().nullable(), organization_id: z.string().uuid(), status: z.enum(["pending", "success", "failed"]), attempt_count: z.number(), + response_code: z.number().nullable(), response_status: z.number().nullable(), response_body: z.string().nullable(), last_attempt_at: z.string().nullable(), @@ -90,6 +96,15 @@ export const webhookDeliverySchema = z.object({ }); export type WebhookDelivery = z.infer; +export const webhookTestResponseSchema = z.object({ + success: z.literal(true), + data: z.object({ + delivery_id: z.string().uuid(), + event_id: z.string().uuid(), + status: z.enum(["pending"]), + }), +}); + export const webhookDlqDeliverySchema = z.object({ id: z.string().uuid(), webhook_id: z.string().uuid(), diff --git a/src/modules/webhooks/webhooks.service.ts b/src/modules/webhooks/webhooks.service.ts index 86cc3e6..7708523 100644 --- a/src/modules/webhooks/webhooks.service.ts +++ b/src/modules/webhooks/webhooks.service.ts @@ -11,6 +11,7 @@ */ import type { FastifyRequest } from "fastify"; +import { randomUUID } from "crypto"; import { webhooksRepository } from "./webhooks.repository.js"; import { validateWebhookUrl, InvalidWebhookUrlError } from "../../utils/url-validator.js"; import { BadRequestError, NotFoundError, ServiceUnavailableError } from "../../utils/errors.js"; @@ -155,4 +156,68 @@ export const webhooksService = { return updated; }, + + /** + * Create and enqueue a synthetic test delivery for a single webhook. + */ + async testWebhook( + request: FastifyRequest, + webhookId: string, + ): Promise<{ delivery_id: string; event_id: string; status: "pending" }> { + const webhook = await webhooksRepository.findWebhookSecretById(request, webhookId); + if (!webhook) throw new NotFoundError("Webhook not found"); + + if (!shouldStartWorkers()) { + throw new ServiceUnavailableError( + "Workers not enabled — webhook delivery requires WORKERS_ENABLED=true", + ); + } + + const eventId = randomUUID(); + const eventType = "webhook.test"; + const occurredAt = new Date().toISOString(); + + await webhooksRepository.createEvent(request, eventId, eventType, { + id: eventId, + type: eventType, + version: 1, + occurred_at: occurredAt, + organization_id: request.organizationId, + data: { + webhook_id: webhook.id, + test: true, + message: "FieldTrack test webhook delivery", + }, + }); + + const delivery = await webhooksRepository.createDelivery( + request, + webhook.id, + eventId, + eventType, + ); + + await enqueueWebhookDelivery( + { + delivery_id: delivery.id, + webhook_id: webhook.id, + event_id: eventId, + url: webhook.url, + secret: webhook.secret, + attempt_number: 1, + }, + 0, + ); + + request.log.info( + { webhookId: webhook.id, deliveryId: delivery.id, eventId }, + "webhooks.service: test delivery enqueued", + ); + + return { + delivery_id: delivery.id, + event_id: eventId, + status: "pending", + }; + }, }; diff --git a/src/utils/event-bus.ts b/src/utils/event-bus.ts index effe288..e4c826f 100644 --- a/src/utils/event-bus.ts +++ b/src/utils/event-bus.ts @@ -80,6 +80,25 @@ export const EVENT_SCHEMA_VERSION = 1 as const; export type EventDataMap = { // ── Attendance ───────────────────────────────────────────────────────────── + /** + * Canonical session check-in event name. + */ + "session.checkin": { + employee_id: string; + session_id: string; + checkin_at: string; + }; + + /** + * Canonical session check-out event name. + */ + "session.checkout": { + employee_id: string; + session_id: string; + checkin_at: string; + checkout_at: string; + }; + /** * Fired immediately after a new attendance session is created (check-in). * diff --git a/src/workers/webhook-event.service.ts b/src/workers/webhook-event.service.ts index 1e13533..75c6fc1 100644 --- a/src/workers/webhook-event.service.ts +++ b/src/workers/webhook-event.service.ts @@ -129,6 +129,7 @@ async function createAndEnqueueDelivery( .insert({ webhook_id: webhook.id, event_id: eventId, + event_type: eventType, organization_id: orgId, status: "pending", attempt_count: 0, @@ -201,6 +202,8 @@ async function createAndEnqueueDelivery( // ─── Event bus subscription ─────────────────────────────────────────────────── const EVENT_NAMES: ReadonlyArray = [ + "session.checkin", + "session.checkout", "employee.checked_in", "employee.checked_out", "expense.created", diff --git a/src/workers/webhook.worker.ts b/src/workers/webhook.worker.ts index 2f07f41..187143f 100644 --- a/src/workers/webhook.worker.ts +++ b/src/workers/webhook.worker.ts @@ -64,6 +64,8 @@ import { env } from "../config/env.js"; * Update this set whenever a new EventDataMap key is added to event-bus.ts. */ const KNOWN_EVENT_TYPES = new Set([ + "session.checkin", + "session.checkout", "employee.checked_in", "employee.checked_out", "expense.created", @@ -165,8 +167,10 @@ async function deliverWebhook( headers: { "Content-Type": "application/json", "X-FieldTrack-Signature": signature, + "X-Webhook-Signature": signature, "X-FieldTrack-Event": eventType, "X-FieldTrack-Timestamp": String(timestamp), + "X-Webhook-Timestamp": String(timestamp), "X-FieldTrack-Delivery-Id": deliveryId, "User-Agent": "FieldTrack-Webhooks/1.0", }, @@ -191,6 +195,7 @@ async function deliverWebhook( */ async function markSuccess( deliveryId: string, + attemptNumber: number, responseStatus: number, responseBody: string, ): Promise { @@ -198,9 +203,11 @@ async function markSuccess( .from("webhook_deliveries") .update({ status: "success", + attempt_count: attemptNumber, response_status: responseStatus, response_body: responseBody, last_attempt_at: new Date().toISOString(), + next_retry_at: null, }) .eq("id", deliveryId); } @@ -432,7 +439,7 @@ export function startWebhookWorker(app: FastifyInstance): Worker | null { const succeeded = status >= 200 && status < 300; if (succeeded) { - await markSuccess(delivery_id, status, body); + await markSuccess(delivery_id, attempt_number, status, body); await recordDeliverySuccess(webhook_id, getCbRedis(), app.log); webhookDeliveriesTotal .labels({ event_type: normalizeEventType(eventType), status: "success" }) diff --git a/tests/integration/admin/webhooks.integration.test.ts b/tests/integration/admin/webhooks.integration.test.ts index a551e36..07a3cf1 100644 --- a/tests/integration/admin/webhooks.integration.test.ts +++ b/tests/integration/admin/webhooks.integration.test.ts @@ -60,6 +60,8 @@ vi.mock("../../../src/modules/webhooks/webhooks.repository.js", () => ({ findDeliveryById: vi.fn(), findWebhookSecretById: vi.fn(), resetDeliveryForRetry: vi.fn(), + createEvent: vi.fn(), + createDelivery: vi.fn(), }, })); @@ -115,9 +117,11 @@ const deliveryRow = { id: DELIVERY_ID, webhook_id: WEBHOOK_ID, event_id: EVENT_ID, + event_type: "expense.created", organization_id: TEST_ORG_ID, status: "failed" as const, attempt_count: 3, + response_code: 500, response_status: 500, response_body: "Internal Server Error", last_attempt_at: now, @@ -429,6 +433,28 @@ describe("Webhooks Admin API", () => { }); }); + // ─── GET /admin/webhooks/logs (alias) ───────────────────────────────────── + + describe("GET /admin/webhooks/logs", () => { + it("returns paginated delivery logs", async () => { + vi.mocked(webhooksRepository.listDeliveries).mockResolvedValueOnce({ + data: [deliveryRow], + total: 1, + }); + + const res = await app.inject({ + method: "GET", + url: "/admin/webhooks/logs", + headers: { authorization: `Bearer ${adminToken}` }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json<{ success: boolean; data: Array }>(); + expect(body.success).toBe(true); + expect(body.data[0].event_type).toBe("expense.created"); + }); + }); + // ─── GET /admin/webhook-dlq ──────────────────────────────────────────────── describe("GET /admin/webhook-dlq", () => { @@ -619,4 +645,35 @@ describe("Webhooks Admin API", () => { expect(res.statusCode).toBe(401); }); }); + + // ─── POST /admin/webhooks/:id/test ──────────────────────────────────────── + + describe("POST /admin/webhooks/:id/test", () => { + it("enqueues a test delivery", async () => { + const { enqueueWebhookDelivery } = await import("../../../src/workers/webhook.queue.js"); + const webhookWithSecret = { + id: WEBHOOK_ID, + url: "https://example.com/hook", + secret: "s3cr3t_value_long_enough", + }; + + vi.mocked(webhooksRepository.findWebhookSecretById).mockResolvedValueOnce(webhookWithSecret); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked((webhooksRepository as any).createEvent).mockResolvedValueOnce(EVENT_ID); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked((webhooksRepository as any).createDelivery).mockResolvedValueOnce({ + ...deliveryRow, + status: "pending", + }); + + const res = await app.inject({ + method: "POST", + url: `/admin/webhooks/${WEBHOOK_ID}/test`, + headers: { authorization: `Bearer ${adminToken}` }, + }); + + expect(res.statusCode).toBe(202); + expect(vi.mocked(enqueueWebhookDelivery)).toHaveBeenCalledOnce(); + }); + }); }); From e167f4de93d35c1b7281a03f33c1c7287c5a064c Mon Sep 17 00:00:00 2001 From: rajashish147 Date: Mon, 13 Apr 2026 02:00:47 +0530 Subject: [PATCH 2/5] feat(api-keys): implement API key management system with CRUD operations, authentication, and rate limiting - Added API key creation, listing, updating, and deletion functionalities. - Implemented API key authentication and scope enforcement for various routes. - Introduced rate limiting for API key usage to prevent abuse. - Created database migration for API keys table with necessary constraints and policies. - Developed integration tests for API key lifecycle and usage tracking. - Enhanced OpenAPI documentation to include API key management endpoints. - Updated Prometheus metrics to track API key requests and errors. --- docs/API.md | 43 +- src/app.ts | 7 + src/config/logger.ts | 33 ++ src/middleware/api-key-scope.ts | 25 ++ src/middleware/auth.ts | 48 ++- .../__tests__/api-keys.integration.test.ts | 404 ++++++++++++++++++ src/modules/api-keys/api-keys.controller.ts | 46 ++ src/modules/api-keys/api-keys.repository.ts | 143 +++++++ src/modules/api-keys/api-keys.routes.ts | 82 ++++ src/modules/api-keys/api-keys.schema.ts | 58 +++ src/modules/api-keys/api-keys.security.ts | 30 ++ src/modules/api-keys/api-keys.service.ts | 44 ++ src/plugins/openapi.plugin.ts | 9 +- src/plugins/prometheus.ts | 21 + src/plugins/security/ratelimit.plugin.ts | 77 +++- src/routes/index.ts | 2 + src/types/global.d.ts | 4 + .../20260413000100_api_keys_platform.sql | 83 ++++ vitest.config.ts | 2 +- 19 files changed, 1154 insertions(+), 7 deletions(-) create mode 100644 src/middleware/api-key-scope.ts create mode 100644 src/modules/api-keys/__tests__/api-keys.integration.test.ts create mode 100644 src/modules/api-keys/api-keys.controller.ts create mode 100644 src/modules/api-keys/api-keys.repository.ts create mode 100644 src/modules/api-keys/api-keys.routes.ts create mode 100644 src/modules/api-keys/api-keys.schema.ts create mode 100644 src/modules/api-keys/api-keys.security.ts create mode 100644 src/modules/api-keys/api-keys.service.ts create mode 100644 supabase/migrations/20260413000100_api_keys_platform.sql diff --git a/docs/API.md b/docs/API.md index f9f00b7..d5c8f77 100644 --- a/docs/API.md +++ b/docs/API.md @@ -40,7 +40,11 @@ This can be imported into API clients like Postman, Insomnia, or used for code g ## Authentication -All API endpoints (except `/health` and `/metrics`) require JWT authentication. +All API endpoints (except `/health` and `/metrics`) require authentication. + +You can authenticate with either: +- `Authorization: Bearer ` (standard user auth) +- `X-API-Key: ` (scoped integration auth) ### Obtaining a Token @@ -63,6 +67,43 @@ Include the JWT token in the `Authorization` header of all API requests: Authorization: Bearer ``` +### Using an API Key + +For machine-to-machine integrations, admins can create scoped API keys from the +Admin UI (`/admin/api-keys`) and then call endpoints with: + +``` +X-API-Key: +``` + +Supported scopes: +- `read:employees` +- `read:sessions` +- `write:expenses` +- `admin:all` + +### API Key Examples + +**cURL** + +```bash +curl -X GET "http://localhost:4000/admin/sessions?page=1&limit=20" \ + -H "X-API-Key: ft_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +``` + +**fetch (Node/Browser)** + +```javascript +const response = await fetch("https://api.fieldtrack.app/admin/employees", { + method: "GET", + headers: { + "X-API-Key": "ft_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + }, +}); + +const result = await response.json(); +``` + ### Authenticating in Swagger UI 1. Open Swagger UI at `/docs` diff --git a/src/app.ts b/src/app.ts index ec84e04..4d75225 100644 --- a/src/app.ts +++ b/src/app.ts @@ -16,6 +16,7 @@ import abuseLoggingPlugin from "./plugins/security/abuse-logging.plugin.js"; // Phase 19: OpenAPI documentation import openApiPlugin from "./plugins/openapi.plugin.js"; import { registerZod } from "./plugins/zod.plugin.js"; +import { apiKeysRepository } from "./modules/api-keys/api-keys.repository.js"; // @fastify/compress intentionally removed: on Node.js >= 22.15, the // peek-stream dependency (fastify-compress-#355) causes silent onSend // hook failures that return an empty body with status 200. @@ -103,6 +104,10 @@ export async function buildApp(): Promise { // immediately visible in Grafana/Loki without a query. // Emits ERROR for responses slower than 2000 ms — indicates a serious problem. app.addHook("onResponse", async (request, reply) => { + if (request.authType === "api_key" && request.apiKeyId && reply.statusCode >= 400) { + void apiKeysRepository.markError(request.apiKeyId).catch(() => undefined); + } + const ms = Math.round(reply.elapsedTime); const logPayload = { requestId: request.id, @@ -113,6 +118,8 @@ export async function buildApp(): Promise { // Populated only for authenticated routes — undefined otherwise userId: (request as { user?: { sub?: string } }).user?.sub, organizationId: (request as { organizationId?: string }).organizationId, + apiKeyId: (request as { apiKeyId?: string }).apiKeyId, + authType: (request as { authType?: "jwt" | "api_key" }).authType, }; if (ms > 2_000) { request.log.error({ ...logPayload, slow_request: true }, "very_slow_response"); diff --git a/src/config/logger.ts b/src/config/logger.ts index 0d44621..628e584 100644 --- a/src/config/logger.ts +++ b/src/config/logger.ts @@ -29,6 +29,17 @@ function otelMixin(): Record { const developmentLogger: LoggerConfig = { level: "debug", mixin: otelMixin, + redact: { + paths: [ + "req.headers.authorization", + "req.headers.x-api-key", + "request.headers.authorization", + "request.headers.x-api-key", + "headers.authorization", + "headers.x-api-key", + ], + censor: "[REDACTED]", + }, transport: { target: "pino-pretty", options: { @@ -42,11 +53,33 @@ const developmentLogger: LoggerConfig = { const productionLogger: LoggerConfig = { level: "info", mixin: otelMixin, + redact: { + paths: [ + "req.headers.authorization", + "req.headers.x-api-key", + "request.headers.authorization", + "request.headers.x-api-key", + "headers.authorization", + "headers.x-api-key", + ], + censor: "[REDACTED]", + }, }; const structuredDebugLogger: LoggerConfig = { level: "debug", mixin: otelMixin, + redact: { + paths: [ + "req.headers.authorization", + "req.headers.x-api-key", + "request.headers.authorization", + "request.headers.x-api-key", + "headers.authorization", + "headers.x-api-key", + ], + censor: "[REDACTED]", + }, }; function canUsePrettyTransport(): boolean { diff --git a/src/middleware/api-key-scope.ts b/src/middleware/api-key-scope.ts new file mode 100644 index 0000000..fc02e16 --- /dev/null +++ b/src/middleware/api-key-scope.ts @@ -0,0 +1,25 @@ +import type { ApiKeyScope } from "../modules/api-keys/api-keys.schema.js"; +import { ForbiddenError } from "../utils/errors.js"; + +function routeScope(method: string, routePath: string): ApiKeyScope | "admin:all" { + if (method === "GET" && routePath.startsWith("/admin/employees")) return "read:employees"; + if (method === "GET" && (routePath.startsWith("/admin/sessions") || routePath === "/attendance/my-sessions")) { + return "read:sessions"; + } + if ((method === "POST" && routePath === "/expenses") || (method === "PATCH" && routePath.startsWith("/admin/expenses/"))) { + return "write:expenses"; + } + return "admin:all"; +} + +export function hasApiKeyScope(scopes: ApiKeyScope[], required: ApiKeyScope | "admin:all"): boolean { + if (scopes.includes("admin:all")) return true; + return scopes.includes(required as ApiKeyScope); +} + +export function enforceApiKeyScope(method: string, routePath: string, scopes: ApiKeyScope[]): void { + const required = routeScope(method.toUpperCase(), routePath); + if (!hasApiKeyScope(scopes, required)) { + throw new ForbiddenError(`API key missing required scope: ${required}`); + } +} diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 6c73b54..4d4abe8 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -2,9 +2,12 @@ import type { FastifyRequest, FastifyReply } from "fastify"; import { trace, context } from "@opentelemetry/api"; import { validate as uuidValidate } from "uuid"; import { jwtPayloadSchema } from "../types/jwt.js"; -import { AppError, UnauthorizedError } from "../utils/errors.js"; +import { AppError, ForbiddenError, UnauthorizedError } from "../utils/errors.js"; import { fail } from "../utils/response.js"; import { verifySupabaseToken } from "../auth/jwtVerifier.js"; +import { apiKeysRepository } from "../modules/api-keys/api-keys.repository.js"; +import { hashApiKey, isApiKeyFormat, safeHashEquals } from "../modules/api-keys/api-keys.security.js"; +import { enforceApiKeyScope } from "./api-key-scope.js"; /** * Layer 2 — Authentication Middleware @@ -26,6 +29,38 @@ export async function authenticate( ): Promise { try { const authHeader = request.headers.authorization; + const rawApiKeyHeader = request.headers["x-api-key"]; + const apiKeyHeader = typeof rawApiKeyHeader === "string" ? rawApiKeyHeader.trim() : undefined; + + if (apiKeyHeader && !authHeader) { + if (!isApiKeyFormat(apiKeyHeader)) { + throw new UnauthorizedError("Invalid API key format", "INVALID_API_KEY"); + } + + const keyHash = hashApiKey(apiKeyHeader); + const keyRecord = await apiKeysRepository.findByHash(keyHash); + + if (!keyRecord || !safeHashEquals(keyRecord.key_hash, keyHash)) { + throw new UnauthorizedError("Invalid API key", "INVALID_API_KEY"); + } + + request.user = { + sub: `api_key:${keyRecord.id}`, + role: "ADMIN", + organization_id: keyRecord.organization_id, + }; + request.organizationId = keyRecord.organization_id; + request.employeeId = undefined; + request.authType = "api_key"; + request.apiKeyId = keyRecord.id; + request.apiKeyScopes = keyRecord.scopes; + + const routePath = request.routeOptions.url ?? request.url; + enforceApiKeyScope(request.method, routePath, keyRecord.scopes); + void apiKeysRepository.markUsed(keyRecord.id).catch(() => undefined); + + return; + } if (!authHeader || !authHeader.startsWith("Bearer ")) { throw new UnauthorizedError("Missing or malformed Authorization header"); @@ -110,6 +145,9 @@ export async function authenticate( request.user = result.data; request.organizationId = result.data.organization_id; request.employeeId = hookEmployeeId ?? undefined; + request.authType = "jwt"; + request.apiKeyId = undefined; + request.apiKeyScopes = undefined; const span = trace.getSpan(context.active()); if (span) { @@ -126,4 +164,12 @@ export async function authenticate( } } +export async function requireJwtAuth( + request: FastifyRequest, +): Promise { + if (request.authType !== "jwt") { + throw new ForbiddenError("This endpoint requires JWT user authentication", "JWT_REQUIRED"); + } +} + diff --git a/src/modules/api-keys/__tests__/api-keys.integration.test.ts b/src/modules/api-keys/__tests__/api-keys.integration.test.ts new file mode 100644 index 0000000..0ddf499 --- /dev/null +++ b/src/modules/api-keys/__tests__/api-keys.integration.test.ts @@ -0,0 +1,404 @@ +import { randomUUID } from "node:crypto"; +import Fastify from "fastify"; +import fastifyJwt from "@fastify/jwt"; +import type { FastifyInstance } from "fastify"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerZod } from "../../../plugins/zod.plugin.js"; +import { apiKeysRoutes } from "../api-keys.routes.js"; +import { authenticate } from "../../../middleware/auth.js"; +import rateLimitPlugin from "../../../plugins/security/ratelimit.plugin.js"; +import { ok } from "../../../utils/response.js"; +import { AppError } from "../../../utils/errors.js"; + +const TEST_ORG_ID = "11111111-1111-4111-8111-111111111111"; +const TEST_ADMIN_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + +interface StoreRow { + id: string; + organization_id: string; + name: string; + key_hash: string; + key_prefix: string; + scopes: Array<"read:employees" | "read:sessions" | "write:expenses" | "admin:all">; + active: boolean; + request_count: number; + error_count: number; + created_at: string; + updated_at: string; + last_used_at: string | null; + revoked_at: string | null; +} + +const keyStore = new Map(); + +function toPublic(row: StoreRow) { + return { + id: row.id, + name: row.name, + scopes: row.scopes, + created_at: row.created_at, + last_used_at: row.last_used_at, + active: row.active, + request_count: row.request_count, + error_count: row.error_count, + key_preview: `${row.key_prefix}...`, + }; +} + +vi.mock("../api-keys.repository.js", () => ({ + apiKeysRepository: { + create: vi.fn(async (request: { organizationId: string; user: { sub: string } }, body: { name: string; scopes: StoreRow["scopes"] }, keyHash: string, keyPrefix: string) => { + const now = new Date().toISOString(); + const row: StoreRow = { + id: randomUUID(), + organization_id: request.organizationId, + name: body.name, + key_hash: keyHash, + key_prefix: keyPrefix, + scopes: body.scopes, + active: true, + request_count: 0, + error_count: 0, + created_at: now, + updated_at: now, + last_used_at: null, + revoked_at: null, + }; + keyStore.set(row.id, row); + return toPublic(row); + }), + + list: vi.fn(async (request: { organizationId: string }) => + Array.from(keyStore.values()) + .filter((row) => row.organization_id === request.organizationId) + .map((row) => toPublic(row))), + + findById: vi.fn(async (request: { organizationId: string }, id: string) => { + const row = keyStore.get(id) ?? null; + if (!row) return null; + if (row.organization_id !== request.organizationId) return null; + return row; + }), + + update: vi.fn(async (request: { organizationId: string }, id: string, body: { name?: string; scopes?: StoreRow["scopes"]; active?: boolean }) => { + const row = keyStore.get(id); + if (!row || row.organization_id !== request.organizationId) { + throw new Error("not found"); + } + if (body.name !== undefined) row.name = body.name; + if (body.scopes !== undefined) row.scopes = body.scopes; + if (body.active !== undefined) { + row.active = body.active; + row.revoked_at = body.active ? null : new Date().toISOString(); + } + row.updated_at = new Date().toISOString(); + keyStore.set(id, row); + return toPublic(row); + }), + + remove: vi.fn(async (request: { organizationId: string }, id: string) => { + const row = keyStore.get(id); + if (!row || row.organization_id !== request.organizationId) { + throw new Error("not found"); + } + keyStore.delete(id); + }), + + findByHash: vi.fn(async (keyHash: string) => { + const row = Array.from(keyStore.values()).find((x) => x.key_hash === keyHash && x.active && x.revoked_at === null); + if (!row) return null; + return { + id: row.id, + organization_id: row.organization_id, + key_hash: row.key_hash, + scopes: row.scopes, + active: row.active, + }; + }), + + markUsed: vi.fn(async (id: string) => { + const row = keyStore.get(id); + if (!row) return; + row.request_count += 1; + row.last_used_at = new Date().toISOString(); + keyStore.set(id, row); + }), + + markError: vi.fn(async (id: string) => { + const row = keyStore.get(id); + if (!row) return; + row.error_count += 1; + keyStore.set(id, row); + }), + }, +})); + +async function buildApiKeyTestApp(): Promise { + const app = Fastify({ logger: false }); + registerZod(app); + + await app.register(fastifyJwt, { + secret: process.env["SUPABASE_JWT_SECRET"] ?? "test-secret", + }); + + await app.register(rateLimitPlugin); + + app.addHook("onResponse", async (request, reply) => { + if (request.authType === "api_key" && request.apiKeyId && reply.statusCode >= 400) { + const { apiKeysRepository } = await import("../api-keys.repository.js"); + void apiKeysRepository.markError(request.apiKeyId); + } + }); + + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + void reply.status(error.statusCode).send({ + success: false, + error: error.message, + requestId: request.id, + }); + return; + } + + const handled = error as { statusCode?: number; message?: string }; + const builtinStatus = handled.statusCode; + if (builtinStatus !== undefined && builtinStatus >= 400 && builtinStatus < 500) { + void reply.status(builtinStatus).send({ + success: false, + error: handled.message ?? "Request failed", + requestId: request.id, + }); + return; + } + + void reply.status(500).send({ + success: false, + error: "Internal server error", + requestId: request.id, + }); + }); + + await app.register(apiKeysRoutes); + + app.get("/admin/employees/probe", { preValidation: [authenticate] }, async () => ok({ route: "employees" })); + app.post("/expenses", { preValidation: [authenticate] }, async () => ok({ route: "expenses" })); + app.get("/admin/system-health/probe", { preValidation: [authenticate] }, async () => ok({ route: "system-health" })); + + await app.ready(); + return app; +} + +function signAdminToken(app: FastifyInstance): string { + const signer = app.jwt as unknown as { sign: (payload: Record) => string }; + return signer.sign({ sub: TEST_ADMIN_ID, role: "ADMIN", org_id: TEST_ORG_ID }); +} + +async function createKey(app: FastifyInstance, adminToken: string, name: string, scopes: StoreRow["scopes"]) { + const res = await app.inject({ + method: "POST", + url: "/admin/api-keys", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ name, scopes }), + }); + + expect(res.statusCode).toBe(201); + return res.json<{ success: true; data: { key: string; record: { id: string } } }>().data; +} + +async function waitForUsageRow( + app: FastifyInstance, + adminToken: string, + keyId: string, +): Promise<{ request_count: number; error_count: number } | undefined> { + for (let i = 0; i < 10; i += 1) { + const listRes = await app.inject({ + method: "GET", + url: "/admin/api-keys", + headers: { authorization: `Bearer ${adminToken}` }, + }); + + const row = listRes + .json<{ success: true; data: Array<{ id: string; request_count: number; error_count: number }> }>() + .data.find((r) => r.id === keyId); + + if ((row?.request_count ?? 0) >= 1 && (row?.error_count ?? 0) >= 1) { + return row; + } + + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + const finalRes = await app.inject({ + method: "GET", + url: "/admin/api-keys", + headers: { authorization: `Bearer ${adminToken}` }, + }); + + return finalRes + .json<{ success: true; data: Array<{ id: string; request_count: number; error_count: number }> }>() + .data.find((r) => r.id === keyId); +} + +describe("API Keys integration", () => { + let app: FastifyInstance; + let adminToken: string; + + beforeAll(async () => { + app = await buildApiKeyTestApp(); + adminToken = signAdminToken(app); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + keyStore.clear(); + vi.clearAllMocks(); + }); + + it("API key lifecycle: create -> list -> disable -> delete", async () => { + const created = await createKey(app, adminToken, "Lifecycle key", ["read:employees"]); + + const list1 = await app.inject({ + method: "GET", + url: "/admin/api-keys", + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(list1.statusCode).toBe(200); + const rows1 = list1.json<{ success: true; data: Array<{ id: string; key_preview: string }> }>().data; + expect(rows1.find((r) => r.id === created.record.id)).toBeTruthy(); + expect(rows1[0].key_preview).toMatch(/^ft_live_[a-f0-9]{8}\.\.\.$/i); + + const disable = await app.inject({ + method: "PATCH", + url: `/admin/api-keys/${created.record.id}`, + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ active: false }), + }); + expect(disable.statusCode).toBe(200); + + const del = await app.inject({ + method: "DELETE", + url: `/admin/api-keys/${created.record.id}`, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(del.statusCode).toBe(204); + }); + + it("Authentication: valid key success, invalid key 401, missing key falls back to JWT", async () => { + const created = await createKey(app, adminToken, "Auth key", ["read:employees"]); + + const valid = await app.inject({ + method: "GET", + url: "/admin/employees/probe", + headers: { "x-api-key": created.key }, + }); + expect(valid.statusCode).toBe(200); + + const invalid = await app.inject({ + method: "GET", + url: "/admin/employees/probe", + headers: { "x-api-key": "ft_live_deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" }, + }); + expect(invalid.statusCode).toBe(401); + + const jwtFallback = await app.inject({ + method: "GET", + url: "/admin/employees/probe", + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(jwtFallback.statusCode).toBe(200); + }); + + it("Scope enforcement: read key cannot write, write key cannot access admin, admin key has full access", async () => { + const readKey = await createKey(app, adminToken, "Read key", ["read:employees"]); + const readOk = await app.inject({ method: "GET", url: "/admin/employees/probe", headers: { "x-api-key": readKey.key } }); + expect(readOk.statusCode).toBe(200); + const readCantWrite = await app.inject({ method: "POST", url: "/expenses", headers: { "x-api-key": readKey.key } }); + expect(readCantWrite.statusCode).toBe(403); + + const writeKey = await createKey(app, adminToken, "Write key", ["write:expenses"]); + const writeOk = await app.inject({ method: "POST", url: "/expenses", headers: { "x-api-key": writeKey.key } }); + expect(writeOk.statusCode).toBe(200); + const writeCantAdmin = await app.inject({ method: "GET", url: "/admin/system-health/probe", headers: { "x-api-key": writeKey.key } }); + expect(writeCantAdmin.statusCode).toBe(403); + + const adminKey = await createKey(app, adminToken, "Admin key", ["admin:all"]); + const adminRead = await app.inject({ method: "GET", url: "/admin/system-health/probe", headers: { "x-api-key": adminKey.key } }); + expect(adminRead.statusCode).toBe(200); + const adminWrite = await app.inject({ method: "POST", url: "/expenses", headers: { "x-api-key": adminKey.key } }); + expect(adminWrite.statusCode).toBe(200); + }); + + it("Rate limiting: exceeding per-key limit returns 429", async () => { + const adminKey = await createKey(app, adminToken, "Burst key", ["admin:all"]); + + let hit429 = false; + for (let i = 0; i < 620; i += 1) { + const res = await app.inject({ + method: "GET", + url: "/admin/system-health/probe", + headers: { "x-api-key": adminKey.key }, + }); + if (res.statusCode === 429) { + hit429 = true; + break; + } + } + + expect(hit429).toBe(true); + }); + + it("Usage tracking: request_count and error_count increment", async () => { + const readKey = await createKey(app, adminToken, "Usage key", ["read:employees"]); + + const successRes = await app.inject({ + method: "GET", + url: "/admin/employees/probe", + headers: { "x-api-key": readKey.key }, + }); + expect(successRes.statusCode).toBe(200); + + const errorRes = await app.inject({ + method: "POST", + url: "/expenses", + headers: { "x-api-key": readKey.key }, + }); + expect(errorRes.statusCode).toBe(403); + + const row = await waitForUsageRow(app, adminToken, readKey.record.id); + + expect(row).toBeTruthy(); + expect((row?.request_count ?? 0) >= 1).toBe(true); + expect((row?.error_count ?? 0) >= 1).toBe(true); + }); + + it("Revocation: disabled key is rejected immediately", async () => { + const created = await createKey(app, adminToken, "Revoked key", ["admin:all"]); + + const disable = await app.inject({ + method: "PATCH", + url: `/admin/api-keys/${created.record.id}`, + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ active: false }), + }); + expect(disable.statusCode).toBe(200); + + const denied = await app.inject({ + method: "GET", + url: "/admin/system-health/probe", + headers: { "x-api-key": created.key }, + }); + expect(denied.statusCode).toBe(401); + }); +}); diff --git a/src/modules/api-keys/api-keys.controller.ts b/src/modules/api-keys/api-keys.controller.ts new file mode 100644 index 0000000..54da245 --- /dev/null +++ b/src/modules/api-keys/api-keys.controller.ts @@ -0,0 +1,46 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; +import { handleError, ok } from "../../utils/response.js"; +import { apiKeysService } from "./api-keys.service.js"; +import { apiKeyCreateBodySchema, apiKeyUpdateBodySchema } from "./api-keys.schema.js"; + +export const apiKeysController = { + async create(request: FastifyRequest, reply: FastifyReply): Promise { + try { + const body = apiKeyCreateBodySchema.parse(request.body); + const result = await apiKeysService.createKey(request, body); + reply.status(201).send(ok(result)); + } catch (error) { + handleError(error, request, reply, "Failed to create API key"); + } + }, + + async list(request: FastifyRequest, reply: FastifyReply): Promise { + try { + const rows = await apiKeysService.listKeys(request); + reply.status(200).send(ok(rows)); + } catch (error) { + handleError(error, request, reply, "Failed to list API keys"); + } + }, + + async update(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply): Promise { + try { + const body = apiKeyUpdateBodySchema.parse(request.body); + const { id } = request.params; + const row = await apiKeysService.updateKey(request, id, body); + reply.status(200).send(ok(row)); + } catch (error) { + handleError(error, request, reply, "Failed to update API key"); + } + }, + + async remove(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply): Promise { + try { + const { id } = request.params; + await apiKeysService.deleteKey(request, id); + reply.status(204).send(); + } catch (error) { + handleError(error, request, reply, "Failed to delete API key"); + } + }, +}; diff --git a/src/modules/api-keys/api-keys.repository.ts b/src/modules/api-keys/api-keys.repository.ts new file mode 100644 index 0000000..3b40297 --- /dev/null +++ b/src/modules/api-keys/api-keys.repository.ts @@ -0,0 +1,143 @@ +import type { FastifyRequest } from "fastify"; +import { supabaseServiceClient as supabase } from "../../config/supabase.js"; +import type { + ApiKeyAuthRecord, + ApiKeyCreateBody, + ApiKeyPublic, + ApiKeyScope, + ApiKeyUpdateBody, +} from "./api-keys.schema.js"; + +interface ApiKeyRow { + id: string; + organization_id: string; + name: string; + key_hash: string; + key_prefix: string; + scopes: ApiKeyScope[]; + active: boolean; + request_count: number; + error_count: number; + created_at: string; + updated_at: string; + last_used_at: string | null; +} + +function toPublic(row: ApiKeyRow): ApiKeyPublic { + return { + id: row.id, + name: row.name, + scopes: row.scopes, + created_at: row.created_at, + last_used_at: row.last_used_at, + active: row.active, + request_count: row.request_count, + error_count: row.error_count, + key_preview: `${row.key_prefix}...`, + }; +} + +export const apiKeysRepository = { + async create( + request: FastifyRequest, + body: ApiKeyCreateBody, + keyHash: string, + keyPrefix: string, + ): Promise { + const { data, error } = await supabase + .from("api_keys") + .insert({ + organization_id: request.organizationId, + name: body.name, + key_hash: keyHash, + key_prefix: keyPrefix, + scopes: body.scopes, + active: true, + created_by: request.user.sub, + }) + .select("id, organization_id, name, key_hash, key_prefix, scopes, active, request_count, error_count, created_at, updated_at, last_used_at") + .single(); + + if (error) throw new Error(`Failed to create API key: ${error.message}`); + return toPublic(data as ApiKeyRow); + }, + + async list(request: FastifyRequest): Promise { + const { data, error } = await supabase + .from("api_keys") + .select("id, organization_id, name, key_hash, key_prefix, scopes, active, request_count, error_count, created_at, updated_at, last_used_at") + .eq("organization_id", request.organizationId) + .order("created_at", { ascending: false }); + + if (error) throw new Error(`Failed to list API keys: ${error.message}`); + return ((data ?? []) as ApiKeyRow[]).map(toPublic); + }, + + async findById(request: FastifyRequest, id: string): Promise { + const { data, error } = await supabase + .from("api_keys") + .select("id, organization_id, name, key_hash, key_prefix, scopes, active, request_count, error_count, created_at, updated_at, last_used_at") + .eq("organization_id", request.organizationId) + .eq("id", id) + .limit(1) + .maybeSingle(); + + if (error) throw new Error(`Failed to fetch API key: ${error.message}`); + return (data as ApiKeyRow | null) ?? null; + }, + + async update(request: FastifyRequest, id: string, body: ApiKeyUpdateBody): Promise { + const patch: Record = {}; + if (body.name !== undefined) patch.name = body.name; + if (body.scopes !== undefined) patch.scopes = body.scopes; + if (body.active !== undefined) { + patch.active = body.active; + patch.revoked_at = body.active ? null : new Date().toISOString(); + } + + const { data, error } = await supabase + .from("api_keys") + .update(patch) + .eq("organization_id", request.organizationId) + .eq("id", id) + .select("id, organization_id, name, key_hash, key_prefix, scopes, active, request_count, error_count, created_at, updated_at, last_used_at") + .single(); + + if (error) throw new Error(`Failed to update API key: ${error.message}`); + return toPublic(data as ApiKeyRow); + }, + + async remove(request: FastifyRequest, id: string): Promise { + const { error } = await supabase + .from("api_keys") + .delete() + .eq("organization_id", request.organizationId) + .eq("id", id); + + if (error) throw new Error(`Failed to delete API key: ${error.message}`); + }, + + async findByHash(keyHash: string): Promise { + const { data, error } = await supabase + .from("api_keys") + .select("id, organization_id, key_hash, scopes, active") + .eq("key_hash", keyHash) + .eq("active", true) + .is("revoked_at", null) + .limit(1) + .maybeSingle(); + + if (error) throw new Error(`Failed to resolve API key: ${error.message}`); + return (data as ApiKeyAuthRecord | null) ?? null; + }, + + async markUsed(id: string): Promise { + const { error } = await supabase.rpc("increment_api_key_usage", { p_key_id: id }); + if (error) throw new Error(`Failed to mark API key usage: ${error.message}`); + }, + + async markError(id: string): Promise { + const { error } = await supabase.rpc("increment_api_key_error", { p_key_id: id }); + if (error) throw new Error(`Failed to mark API key error: ${error.message}`); + }, +}; diff --git a/src/modules/api-keys/api-keys.routes.ts b/src/modules/api-keys/api-keys.routes.ts new file mode 100644 index 0000000..874d545 --- /dev/null +++ b/src/modules/api-keys/api-keys.routes.ts @@ -0,0 +1,82 @@ +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { authenticate, requireJwtAuth } from "../../middleware/auth.js"; +import { requireRole } from "../../middleware/role-guard.js"; +import { apiKeysController } from "./api-keys.controller.js"; +import { apiKeyCreateBodySchema, apiKeyPublicSchema, apiKeyUpdateBodySchema } from "./api-keys.schema.js"; + +const apiKeyCreateResponseSchema = z.object({ + success: z.literal(true), + data: z.object({ + key: z.string(), + record: apiKeyPublicSchema, + }), +}); + +const apiKeyListResponseSchema = z.object({ + success: z.literal(true), + data: z.array(apiKeyPublicSchema), +}); + +const apiKeySingleResponseSchema = z.object({ + success: z.literal(true), + data: apiKeyPublicSchema, +}); + +export async function apiKeysRoutes(app: FastifyInstance): Promise { + app.post( + "/admin/api-keys", + { + schema: { + tags: ["admin", "api-keys"], + summary: "Create API key (raw key returned only once)", + body: apiKeyCreateBodySchema, + response: { 201: apiKeyCreateResponseSchema }, + }, + preValidation: [authenticate, requireJwtAuth, requireRole("ADMIN")], + }, + apiKeysController.create, + ); + + app.get( + "/admin/api-keys", + { + schema: { + tags: ["admin", "api-keys"], + summary: "List API keys for organization", + response: { 200: apiKeyListResponseSchema }, + }, + preValidation: [authenticate, requireJwtAuth, requireRole("ADMIN")], + }, + apiKeysController.list, + ); + + app.patch<{ Params: { id: string } }>( + "/admin/api-keys/:id", + { + schema: { + tags: ["admin", "api-keys"], + summary: "Update API key metadata, scopes or active state", + params: z.object({ id: z.string().uuid() }), + body: apiKeyUpdateBodySchema, + response: { 200: apiKeySingleResponseSchema }, + }, + preValidation: [authenticate, requireJwtAuth, requireRole("ADMIN")], + }, + apiKeysController.update, + ); + + app.delete<{ Params: { id: string } }>( + "/admin/api-keys/:id", + { + schema: { + tags: ["admin", "api-keys"], + summary: "Delete API key", + params: z.object({ id: z.string().uuid() }), + response: { 204: z.null().describe("No content") }, + }, + preValidation: [authenticate, requireJwtAuth, requireRole("ADMIN")], + }, + apiKeysController.remove, + ); +} diff --git a/src/modules/api-keys/api-keys.schema.ts b/src/modules/api-keys/api-keys.schema.ts new file mode 100644 index 0000000..ba41f70 --- /dev/null +++ b/src/modules/api-keys/api-keys.schema.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; + +export const API_KEY_SCOPES = [ + "read:employees", + "read:sessions", + "write:expenses", + "admin:all", +] as const; + +export type ApiKeyScope = (typeof API_KEY_SCOPES)[number]; + +export const apiKeyScopeSchema = z.enum(API_KEY_SCOPES); + +export const apiKeyPublicSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + scopes: z.array(apiKeyScopeSchema), + created_at: z.string(), + last_used_at: z.string().nullable(), + active: z.boolean(), + request_count: z.number(), + error_count: z.number(), + key_preview: z.string(), +}); + +export type ApiKeyPublic = z.infer; + +export const apiKeyCreateBodySchema = z.object({ + name: z.string().min(3).max(64), + scopes: z.array(apiKeyScopeSchema).min(1), +}); + +export type ApiKeyCreateBody = z.infer; + +export const apiKeyUpdateBodySchema = z + .object({ + name: z.string().min(3).max(64).optional(), + scopes: z.array(apiKeyScopeSchema).min(1).optional(), + active: z.boolean().optional(), + }) + .refine((v) => v.name !== undefined || v.scopes !== undefined || v.active !== undefined, { + message: "At least one field must be provided", + }); + +export type ApiKeyUpdateBody = z.infer; + +export interface ApiKeyCreateResult { + key: string; + record: ApiKeyPublic; +} + +export interface ApiKeyAuthRecord { + id: string; + organization_id: string; + key_hash: string; + scopes: ApiKeyScope[]; + active: boolean; +} diff --git a/src/modules/api-keys/api-keys.security.ts b/src/modules/api-keys/api-keys.security.ts new file mode 100644 index 0000000..39733f5 --- /dev/null +++ b/src/modules/api-keys/api-keys.security.ts @@ -0,0 +1,30 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; + +const API_KEY_PREFIX = "ft_live_"; + +export function generateRawApiKey(): string { + return `${API_KEY_PREFIX}${randomBytes(24).toString("hex")}`; +} + +export function hashApiKey(raw: string): string { + return createHash("sha256").update(raw, "utf8").digest("hex"); +} + +export function getKeyPrefix(raw: string): string { + return raw.slice(0, 16); +} + +export function getKeyPreview(raw: string): string { + const start = raw.slice(0, 11); + const end = raw.slice(-4); + return `${start}...${end}`; +} + +export function isApiKeyFormat(raw: string): boolean { + return /^ft_live_[a-f0-9]{48}$/i.test(raw); +} + +export function safeHashEquals(expectedHex: string, actualHex: string): boolean { + if (expectedHex.length !== actualHex.length) return false; + return timingSafeEqual(Buffer.from(expectedHex, "utf8"), Buffer.from(actualHex, "utf8")); +} diff --git a/src/modules/api-keys/api-keys.service.ts b/src/modules/api-keys/api-keys.service.ts new file mode 100644 index 0000000..95679f4 --- /dev/null +++ b/src/modules/api-keys/api-keys.service.ts @@ -0,0 +1,44 @@ +import type { FastifyRequest } from "fastify"; +import { BadRequestError, NotFoundError } from "../../utils/errors.js"; +import { apiKeysRepository } from "./api-keys.repository.js"; +import type { ApiKeyCreateBody, ApiKeyCreateResult, ApiKeyPublic, ApiKeyUpdateBody } from "./api-keys.schema.js"; +import { generateRawApiKey, getKeyPrefix, getKeyPreview, hashApiKey } from "./api-keys.security.js"; + +export const apiKeysService = { + async createKey(request: FastifyRequest, body: ApiKeyCreateBody): Promise { + const raw = generateRawApiKey(); + const keyHash = hashApiKey(raw); + const keyPrefix = getKeyPrefix(raw); + + const record = await apiKeysRepository.create(request, body, keyHash, keyPrefix); + + return { + key: raw, + record: { + ...record, + key_preview: getKeyPreview(raw), + }, + }; + }, + + async listKeys(request: FastifyRequest): Promise { + return apiKeysRepository.list(request); + }, + + async updateKey(request: FastifyRequest, id: string, body: ApiKeyUpdateBody): Promise { + const existing = await apiKeysRepository.findById(request, id); + if (!existing) throw new NotFoundError("API key not found"); + + if (body.scopes?.includes("admin:all") && body.scopes.length > 1) { + throw new BadRequestError("admin:all cannot be combined with other scopes"); + } + + return apiKeysRepository.update(request, id, body); + }, + + async deleteKey(request: FastifyRequest, id: string): Promise { + const existing = await apiKeysRepository.findById(request, id); + if (!existing) throw new NotFoundError("API key not found"); + await apiKeysRepository.remove(request, id); + }, +}; diff --git a/src/plugins/openapi.plugin.ts b/src/plugins/openapi.plugin.ts index 652041b..6cde9c5 100644 --- a/src/plugins/openapi.plugin.ts +++ b/src/plugins/openapi.plugin.ts @@ -113,6 +113,7 @@ async function openApiPlugin(app: FastifyInstance): Promise { { name: "expenses", description: "Expense reporting and management" }, { name: "analytics", description: "Business analytics and reporting" }, { name: "admin", description: "Administrative operations (ADMIN role required)" }, + { name: "api-keys", description: "API key management and scoped external access" }, { name: "dashboard", description: "Employee dashboard and personal statistics" }, { name: "profile", description: "Employee profile and activity status" }, ], @@ -126,6 +127,12 @@ async function openApiPlugin(app: FastifyInstance): Promise { "JWT token obtained from authentication service. " + "Include in Authorization header: `Bearer `", }, + ApiKeyAuth: { + type: "apiKey", + in: "header", + name: "X-API-Key", + description: "Scoped API key for external integrations", + }, }, schemas: { SuccessResponse: { @@ -243,7 +250,7 @@ async function openApiPlugin(app: FastifyInstance): Promise { }, }, }, - security: [{ BearerAuth: [] }], + security: [{ BearerAuth: [] }, { ApiKeyAuth: [] }], externalDocs: { url: "https://github.com/fieldtrack-tech/fieldtrack-2.0", description: "Find more info here", diff --git a/src/plugins/prometheus.ts b/src/plugins/prometheus.ts index 45044a3..3ed5352 100644 --- a/src/plugins/prometheus.ts +++ b/src/plugins/prometheus.ts @@ -69,6 +69,20 @@ export const securityAuthBruteforce = new client.Counter({ registers: [register], }); +export const apiKeyRequestsTotal = new client.Counter({ + name: "api_key_requests_total", + help: "Total number of authenticated API key requests", + labelNames: ["route", "status_code"], + registers: [register], +}); + +export const apiKeyErrorsTotal = new client.Counter({ + name: "api_key_errors_total", + help: "Total number of API key requests resulting in client/server errors", + labelNames: ["route", "status_code"], + registers: [register], +}); + // ─── Phase 21: Analytics Worker Metrics ────────────────────────────────────── /** @@ -314,6 +328,13 @@ const prometheusPlugin: FastifyPluginAsync = async (fastify) => { return; } + if (request.authType === "api_key") { + apiKeyRequestsTotal.labels(route, String(reply.statusCode)).inc(); + if (reply.statusCode >= 400) { + apiKeyErrorsTotal.labels(route, String(reply.statusCode)).inc(); + } + } + httpRequestsTotal .labels(request.method, route, String(reply.statusCode)) .inc(); diff --git a/src/plugins/security/ratelimit.plugin.ts b/src/plugins/security/ratelimit.plugin.ts index fa28b7a..df7ffde 100644 --- a/src/plugins/security/ratelimit.plugin.ts +++ b/src/plugins/security/ratelimit.plugin.ts @@ -38,6 +38,8 @@ import { shouldStartWorkers } from "../../workers/startup.js"; const ORG_RATE_LIMIT_MAX = 5_000; /** Sliding-window size in milliseconds. */ const ORG_RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute +const API_KEY_RATE_LIMIT_MAX = 600; +const API_KEY_RATE_LIMIT_WINDOW_MS = 60_000; /** * Lua script: atomic sliding-window check + record using a sorted set. @@ -74,12 +76,15 @@ const rateLimitPlugin: FastifyPluginAsync = async (fastify: FastifyInstance) => // Redis-backed tier because the counter is per-process (not shared // across replicas), making per-user tracking less accurate. fastify.log.warn( - "security-rate-limit plugin using in-memory fallback (Redis not provisioned) — limits: 200 req/min", + "security-rate-limit plugin using in-memory fallback (Redis not provisioned) — limits: 1200 req/min + api-key 600 req/min", ); + + const apiKeyHits = new Map(); + await fastify.register(fastifyRateLimit, { global: true, hook: "preHandler", - max: 200, + max: 1200, timeWindow: "1 minute", allowList: ["127.0.0.1", "::1"], errorResponseBuilder: (_request, context) => ({ @@ -88,7 +93,28 @@ const rateLimitPlugin: FastifyPluginAsync = async (fastify: FastifyInstance) => retryAfter: context.after, }), }); - fastify.log.info("security-rate-limit plugin registered (in-memory fallback, 200 req/min)"); + + fastify.addHook("preHandler", async (request, reply) => { + const apiKeyId = (request as { apiKeyId?: string }).apiKeyId; + if (!apiKeyId) return; + + const nowMs = Date.now(); + const cutoff = nowMs - API_KEY_RATE_LIMIT_WINDOW_MS; + const bucket = apiKeyHits.get(apiKeyId) ?? []; + const kept = bucket.filter((ts) => ts > cutoff); + kept.push(nowMs); + apiKeyHits.set(apiKeyId, kept); + + if (kept.length > API_KEY_RATE_LIMIT_MAX) { + void reply.status(429).send({ + success: false, + error: "API key rate limit exceeded", + retryAfter: `${Math.ceil(API_KEY_RATE_LIMIT_WINDOW_MS / 1000)}s`, + }); + } + }); + + fastify.log.info("security-rate-limit plugin registered (in-memory fallback, user:1200 req/min, api-key:600 req/min)"); return; } @@ -127,6 +153,51 @@ const rateLimitPlugin: FastifyPluginAsync = async (fastify: FastifyInstance) => const slidingWindowSha = await orgRlRedis.script("LOAD", SLIDING_WINDOW_LUA) as string; fastify.addHook("preHandler", async (request, reply) => { + const apiKeyId = (request as { apiKeyId?: string }).apiKeyId; + if (apiKeyId) { + const nowMs = Date.now(); + const member = `${nowMs}:${Math.random().toString(36).slice(2)}`; + const key = `rl:api-key:${apiKeyId}`; + const ttlMs = Math.round( + API_KEY_RATE_LIMIT_WINDOW_MS * 2 + API_KEY_RATE_LIMIT_WINDOW_MS * 0.1 * Math.random(), + ); + + try { + const keyCount = await orgRlRedis + .evalsha( + slidingWindowSha, + 1, + key, + String(nowMs), + String(API_KEY_RATE_LIMIT_WINDOW_MS), + member, + String(ttlMs), + ) + .catch(() => + orgRlRedis.eval( + SLIDING_WINDOW_LUA, + 1, + key, + String(nowMs), + String(API_KEY_RATE_LIMIT_WINDOW_MS), + member, + String(ttlMs), + ), + ) as number; + + if (keyCount > API_KEY_RATE_LIMIT_MAX) { + void reply.status(429).send({ + success: false, + error: "API key rate limit exceeded", + retryAfter: `${Math.ceil(API_KEY_RATE_LIMIT_WINDOW_MS / 1000)}s`, + }); + return; + } + } catch { + return; + } + } + const orgId = (request as { organizationId?: string }).organizationId; if (!orgId) return; if (request.ip === "127.0.0.1" || request.ip === "::1") return; diff --git a/src/routes/index.ts b/src/routes/index.ts index 835146e..b73d8c0 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -21,6 +21,7 @@ import { auditLogRoutes } from "../modules/admin/audit-log.routes.js"; import { adminQueuesRoutes } from "../modules/admin/queues.routes.js"; import { adminRetryIntentsRoutes } from "../modules/admin/retry-intents.routes.js"; import { systemHealthRoutes } from "../modules/admin/system-health.routes.js"; +import { apiKeysRoutes } from "../modules/api-keys/api-keys.routes.js"; import { adminForceCheckoutRoutes } from "../modules/admin/force-checkout.routes.js"; @@ -48,4 +49,5 @@ export async function registerRoutes(app: FastifyInstance): Promise { await app.register(adminRetryIntentsRoutes); await app.register(systemHealthRoutes); await app.register(adminForceCheckoutRoutes); + await app.register(apiKeysRoutes); } diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 0d239f9..87df636 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1,5 +1,6 @@ import "fastify"; import type { JwtPayload } from "./jwt.js"; +import type { ApiKeyScope } from "../modules/api-keys/api-keys.schema.js"; declare module "@fastify/jwt" { interface FastifyJWT { @@ -13,6 +14,9 @@ declare module "fastify" { user: JwtPayload; // Authenticated user information organizationId: string; // Tenant context employeeId?: string; // employees.id resolved from users.id at auth time (undefined for ADMINs) + authType?: "jwt" | "api_key"; + apiKeyId?: string; + apiKeyScopes?: ApiKeyScope[]; // Phase 18: Internal Fastify property for matched route pattern. // Used by Prometheus metrics and abuse logging to group requests by route. routerPath?: string; diff --git a/supabase/migrations/20260413000100_api_keys_platform.sql b/supabase/migrations/20260413000100_api_keys_platform.sql new file mode 100644 index 0000000..b24f9d5 --- /dev/null +++ b/supabase/migrations/20260413000100_api_keys_platform.sql @@ -0,0 +1,83 @@ +-- API keys platform +create table if not exists public.api_keys ( + id uuid primary key default gen_random_uuid(), + organization_id uuid not null references public.organizations(id) on delete cascade, + name text not null, + key_hash text not null unique, + key_prefix text not null, + scopes text[] not null default array[]::text[], + active boolean not null default true, + request_count bigint not null default 0, + error_count bigint not null default 0, + created_by uuid references auth.users(id) on delete set null, + last_used_at timestamptz, + revoked_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint api_keys_name_len check (char_length(name) between 3 and 64), + constraint api_keys_scope_values check ( + scopes <@ array['read:employees','read:sessions','write:expenses','admin:all']::text[] + ), + constraint api_keys_admin_scope_exclusive check ( + not ('admin:all' = any(scopes) and array_length(scopes, 1) > 1) + ) +); + +create index if not exists idx_api_keys_org on public.api_keys(organization_id); +create index if not exists idx_api_keys_org_active on public.api_keys(organization_id, active); +create index if not exists idx_api_keys_last_used_at on public.api_keys(last_used_at desc nulls last); +create unique index if not exists idx_api_keys_prefix_hash on public.api_keys(key_prefix, key_hash); + +drop trigger if exists trg_api_keys_updated_at on public.api_keys; +create trigger trg_api_keys_updated_at +before update on public.api_keys +for each row execute function public.set_updated_at(); + +alter table public.api_keys enable row level security; + +create policy api_keys_select_same_org on public.api_keys +for select +using (organization_id::text = coalesce(auth.jwt() ->> 'org_id', '')); + +create policy api_keys_insert_same_org on public.api_keys +for insert +with check (organization_id::text = coalesce(auth.jwt() ->> 'org_id', '')); + +create policy api_keys_update_same_org on public.api_keys +for update +using (organization_id::text = coalesce(auth.jwt() ->> 'org_id', '')) +with check (organization_id::text = coalesce(auth.jwt() ->> 'org_id', '')); + +create policy api_keys_delete_same_org on public.api_keys +for delete +using (organization_id::text = coalesce(auth.jwt() ->> 'org_id', '')); + +create or replace function public.increment_api_key_usage(p_key_id uuid) +returns void +language plpgsql +security definer +set search_path = public +as $$ +begin + update public.api_keys + set request_count = request_count + 1, + last_used_at = now() + where id = p_key_id and active = true and revoked_at is null; +end; +$$; + +create or replace function public.increment_api_key_error(p_key_id uuid) +returns void +language plpgsql +security definer +set search_path = public +as $$ +begin + update public.api_keys + set error_count = error_count + 1 + where id = p_key_id; +end; +$$; + +grant execute on function public.increment_api_key_usage(uuid) to service_role; +grant execute on function public.increment_api_key_error(uuid) to service_role; diff --git a/vitest.config.ts b/vitest.config.ts index b6070fa..f8d6b34 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ // Run env-setup before every test file so required env vars are set // before any project module is imported. setupFiles: ["./tests/setup/env-setup.ts", "./tests/setup/mock-jwt-verifier.ts"], - include: ["tests/**/*.test.ts"], + include: ["tests/**/*.test.ts", "src/**/__tests__/**/*.test.ts"], // Reset mock call history (but not implementations) between tests. clearMocks: true, coverage: { From 20abce36c0bb452645b9fbfaef2557a974532cd8 Mon Sep 17 00:00:00 2001 From: rajashish147 Date: Mon, 13 Apr 2026 02:04:41 +0530 Subject: [PATCH 3/5] fix(tests): update JWT secret retrieval to use environment configuration --- src/modules/api-keys/__tests__/api-keys.integration.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/api-keys/__tests__/api-keys.integration.test.ts b/src/modules/api-keys/__tests__/api-keys.integration.test.ts index 0ddf499..0359cb5 100644 --- a/src/modules/api-keys/__tests__/api-keys.integration.test.ts +++ b/src/modules/api-keys/__tests__/api-keys.integration.test.ts @@ -9,6 +9,7 @@ import { authenticate } from "../../../middleware/auth.js"; import rateLimitPlugin from "../../../plugins/security/ratelimit.plugin.js"; import { ok } from "../../../utils/response.js"; import { AppError } from "../../../utils/errors.js"; +import { env } from "../../../config/env.js"; const TEST_ORG_ID = "11111111-1111-4111-8111-111111111111"; const TEST_ADMIN_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; @@ -138,7 +139,7 @@ async function buildApiKeyTestApp(): Promise { registerZod(app); await app.register(fastifyJwt, { - secret: process.env["SUPABASE_JWT_SECRET"] ?? "test-secret", + secret: env.SUPABASE_JWT_SECRET, }); await app.register(rateLimitPlugin); From 4a237685637a929216465bb5f7bd60596c52b148 Mon Sep 17 00:00:00 2001 From: rajashish147 Date: Mon, 13 Apr 2026 02:17:30 +0530 Subject: [PATCH 4/5] fix(tests): update JWT secret in API keys integration tests to use a constant --- src/modules/api-keys/__tests__/api-keys.integration.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/api-keys/__tests__/api-keys.integration.test.ts b/src/modules/api-keys/__tests__/api-keys.integration.test.ts index 0359cb5..01d0f65 100644 --- a/src/modules/api-keys/__tests__/api-keys.integration.test.ts +++ b/src/modules/api-keys/__tests__/api-keys.integration.test.ts @@ -9,10 +9,10 @@ import { authenticate } from "../../../middleware/auth.js"; import rateLimitPlugin from "../../../plugins/security/ratelimit.plugin.js"; import { ok } from "../../../utils/response.js"; import { AppError } from "../../../utils/errors.js"; -import { env } from "../../../config/env.js"; const TEST_ORG_ID = "11111111-1111-4111-8111-111111111111"; const TEST_ADMIN_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; +const TEST_JWT_SECRET = "test-secret"; interface StoreRow { id: string; @@ -139,7 +139,7 @@ async function buildApiKeyTestApp(): Promise { registerZod(app); await app.register(fastifyJwt, { - secret: env.SUPABASE_JWT_SECRET, + secret: TEST_JWT_SECRET, }); await app.register(rateLimitPlugin); From e7d75662cb064d27a062dc593a84c98dc3a966c5 Mon Sep 17 00:00:00 2001 From: rajashish147 Date: Mon, 13 Apr 2026 02:28:57 +0530 Subject: [PATCH 5/5] feat(api-keys): implement per-key random salt for API key hashing and update related logic --- src/middleware/auth.ts | 9 ++--- .../__tests__/api-keys.integration.test.ts | 28 ++++++++------- src/modules/api-keys/api-keys.repository.ts | 22 ++++++------ src/modules/api-keys/api-keys.schema.ts | 1 + src/modules/api-keys/api-keys.security.ts | 36 ++++++++++++++++--- src/modules/api-keys/api-keys.service.ts | 4 +-- .../20260413000200_api_keys_per_key_salt.sql | 24 +++++++++++++ 7 files changed, 90 insertions(+), 34 deletions(-) create mode 100644 supabase/migrations/20260413000200_api_keys_per_key_salt.sql diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 4d4abe8..09fd3de 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -6,7 +6,7 @@ import { AppError, ForbiddenError, UnauthorizedError } from "../utils/errors.js" import { fail } from "../utils/response.js"; import { verifySupabaseToken } from "../auth/jwtVerifier.js"; import { apiKeysRepository } from "../modules/api-keys/api-keys.repository.js"; -import { hashApiKey, isApiKeyFormat, safeHashEquals } from "../modules/api-keys/api-keys.security.js"; +import { getKeyPrefix, isApiKeyFormat, verifyApiKey } from "../modules/api-keys/api-keys.security.js"; import { enforceApiKeyScope } from "./api-key-scope.js"; /** @@ -37,10 +37,11 @@ export async function authenticate( throw new UnauthorizedError("Invalid API key format", "INVALID_API_KEY"); } - const keyHash = hashApiKey(apiKeyHeader); - const keyRecord = await apiKeysRepository.findByHash(keyHash); + const keyPrefix = getKeyPrefix(apiKeyHeader); + const candidates = await apiKeysRepository.findActiveByPrefix(keyPrefix); + const keyRecord = candidates.find((record) => verifyApiKey(apiKeyHeader, record.key_hash, record.key_salt)); - if (!keyRecord || !safeHashEquals(keyRecord.key_hash, keyHash)) { + if (!keyRecord) { throw new UnauthorizedError("Invalid API key", "INVALID_API_KEY"); } diff --git a/src/modules/api-keys/__tests__/api-keys.integration.test.ts b/src/modules/api-keys/__tests__/api-keys.integration.test.ts index 01d0f65..baf5fc1 100644 --- a/src/modules/api-keys/__tests__/api-keys.integration.test.ts +++ b/src/modules/api-keys/__tests__/api-keys.integration.test.ts @@ -19,6 +19,7 @@ interface StoreRow { organization_id: string; name: string; key_hash: string; + key_salt: string; key_prefix: string; scopes: Array<"read:employees" | "read:sessions" | "write:expenses" | "admin:all">; active: boolean; @@ -48,13 +49,14 @@ function toPublic(row: StoreRow) { vi.mock("../api-keys.repository.js", () => ({ apiKeysRepository: { - create: vi.fn(async (request: { organizationId: string; user: { sub: string } }, body: { name: string; scopes: StoreRow["scopes"] }, keyHash: string, keyPrefix: string) => { + create: vi.fn(async (request: { organizationId: string; user: { sub: string } }, body: { name: string; scopes: StoreRow["scopes"] }, keyHash: string, keySalt: string, keyPrefix: string) => { const now = new Date().toISOString(); const row: StoreRow = { id: randomUUID(), organization_id: request.organizationId, name: body.name, key_hash: keyHash, + key_salt: keySalt, key_prefix: keyPrefix, scopes: body.scopes, active: true, @@ -105,17 +107,17 @@ vi.mock("../api-keys.repository.js", () => ({ keyStore.delete(id); }), - findByHash: vi.fn(async (keyHash: string) => { - const row = Array.from(keyStore.values()).find((x) => x.key_hash === keyHash && x.active && x.revoked_at === null); - if (!row) return null; - return { - id: row.id, - organization_id: row.organization_id, - key_hash: row.key_hash, - scopes: row.scopes, - active: row.active, - }; - }), + findActiveByPrefix: vi.fn(async (keyPrefix: string) => + Array.from(keyStore.values()) + .filter((x) => x.key_prefix === keyPrefix && x.active && x.revoked_at === null) + .map((row) => ({ + id: row.id, + organization_id: row.organization_id, + key_hash: row.key_hash, + key_salt: row.key_salt, + scopes: row.scopes, + active: row.active, + }))), markUsed: vi.fn(async (id: string) => { const row = keyStore.get(id); @@ -355,7 +357,7 @@ describe("API Keys integration", () => { } expect(hit429).toBe(true); - }); + }, 60_000); it("Usage tracking: request_count and error_count increment", async () => { const readKey = await createKey(app, adminToken, "Usage key", ["read:employees"]); diff --git a/src/modules/api-keys/api-keys.repository.ts b/src/modules/api-keys/api-keys.repository.ts index 3b40297..f673adf 100644 --- a/src/modules/api-keys/api-keys.repository.ts +++ b/src/modules/api-keys/api-keys.repository.ts @@ -13,6 +13,7 @@ interface ApiKeyRow { organization_id: string; name: string; key_hash: string; + key_salt: string; key_prefix: string; scopes: ApiKeyScope[]; active: boolean; @@ -42,6 +43,7 @@ export const apiKeysRepository = { request: FastifyRequest, body: ApiKeyCreateBody, keyHash: string, + keySalt: string, keyPrefix: string, ): Promise { const { data, error } = await supabase @@ -50,12 +52,13 @@ export const apiKeysRepository = { organization_id: request.organizationId, name: body.name, key_hash: keyHash, + key_salt: keySalt, key_prefix: keyPrefix, scopes: body.scopes, active: true, created_by: request.user.sub, }) - .select("id, organization_id, name, key_hash, key_prefix, scopes, active, request_count, error_count, created_at, updated_at, last_used_at") + .select("id, organization_id, name, key_hash, key_salt, key_prefix, scopes, active, request_count, error_count, created_at, updated_at, last_used_at") .single(); if (error) throw new Error(`Failed to create API key: ${error.message}`); @@ -65,7 +68,7 @@ export const apiKeysRepository = { async list(request: FastifyRequest): Promise { const { data, error } = await supabase .from("api_keys") - .select("id, organization_id, name, key_hash, key_prefix, scopes, active, request_count, error_count, created_at, updated_at, last_used_at") + .select("id, organization_id, name, key_hash, key_salt, key_prefix, scopes, active, request_count, error_count, created_at, updated_at, last_used_at") .eq("organization_id", request.organizationId) .order("created_at", { ascending: false }); @@ -76,7 +79,7 @@ export const apiKeysRepository = { async findById(request: FastifyRequest, id: string): Promise { const { data, error } = await supabase .from("api_keys") - .select("id, organization_id, name, key_hash, key_prefix, scopes, active, request_count, error_count, created_at, updated_at, last_used_at") + .select("id, organization_id, name, key_hash, key_salt, key_prefix, scopes, active, request_count, error_count, created_at, updated_at, last_used_at") .eq("organization_id", request.organizationId) .eq("id", id) .limit(1) @@ -100,7 +103,7 @@ export const apiKeysRepository = { .update(patch) .eq("organization_id", request.organizationId) .eq("id", id) - .select("id, organization_id, name, key_hash, key_prefix, scopes, active, request_count, error_count, created_at, updated_at, last_used_at") + .select("id, organization_id, name, key_hash, key_salt, key_prefix, scopes, active, request_count, error_count, created_at, updated_at, last_used_at") .single(); if (error) throw new Error(`Failed to update API key: ${error.message}`); @@ -117,18 +120,17 @@ export const apiKeysRepository = { if (error) throw new Error(`Failed to delete API key: ${error.message}`); }, - async findByHash(keyHash: string): Promise { + async findActiveByPrefix(keyPrefix: string): Promise { const { data, error } = await supabase .from("api_keys") - .select("id, organization_id, key_hash, scopes, active") - .eq("key_hash", keyHash) + .select("id, organization_id, key_hash, key_salt, scopes, active") + .eq("key_prefix", keyPrefix) .eq("active", true) .is("revoked_at", null) - .limit(1) - .maybeSingle(); + .limit(10); if (error) throw new Error(`Failed to resolve API key: ${error.message}`); - return (data as ApiKeyAuthRecord | null) ?? null; + return (data as ApiKeyAuthRecord[] | null) ?? []; }, async markUsed(id: string): Promise { diff --git a/src/modules/api-keys/api-keys.schema.ts b/src/modules/api-keys/api-keys.schema.ts index ba41f70..21be0bc 100644 --- a/src/modules/api-keys/api-keys.schema.ts +++ b/src/modules/api-keys/api-keys.schema.ts @@ -53,6 +53,7 @@ export interface ApiKeyAuthRecord { id: string; organization_id: string; key_hash: string; + key_salt: string; scopes: ApiKeyScope[]; active: boolean; } diff --git a/src/modules/api-keys/api-keys.security.ts b/src/modules/api-keys/api-keys.security.ts index 39733f5..29e7638 100644 --- a/src/modules/api-keys/api-keys.security.ts +++ b/src/modules/api-keys/api-keys.security.ts @@ -1,13 +1,26 @@ -import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; const API_KEY_PREFIX = "ft_live_"; +const API_KEY_HASH_BYTES = 32; +const API_KEY_SALT_BYTES = 16; + +export interface ApiKeyHashResult { + hash: string; + salt: string; +} export function generateRawApiKey(): string { return `${API_KEY_PREFIX}${randomBytes(24).toString("hex")}`; } -export function hashApiKey(raw: string): string { - return createHash("sha256").update(raw, "utf8").digest("hex"); +function deriveApiKeyHash(raw: string, saltHex: string): string { + return scryptSync(raw, Buffer.from(saltHex, "hex"), API_KEY_HASH_BYTES).toString("hex"); +} + +export function hashApiKey(raw: string): ApiKeyHashResult { + const salt = randomBytes(API_KEY_SALT_BYTES).toString("hex"); + const hash = deriveApiKeyHash(raw, salt); + return { hash, salt }; } export function getKeyPrefix(raw: string): string { @@ -25,6 +38,19 @@ export function isApiKeyFormat(raw: string): boolean { } export function safeHashEquals(expectedHex: string, actualHex: string): boolean { - if (expectedHex.length !== actualHex.length) return false; - return timingSafeEqual(Buffer.from(expectedHex, "utf8"), Buffer.from(actualHex, "utf8")); + if (expectedHex.length !== actualHex.length || expectedHex.length % 2 !== 0) return false; + + const expected = Buffer.from(expectedHex, "hex"); + const actual = Buffer.from(actualHex, "hex"); + if (expected.length !== actual.length || expected.length === 0) return false; + + return timingSafeEqual(expected, actual); +} + +export function verifyApiKey(raw: string, expectedHashHex: string, saltHex: string): boolean { + const normalizedSalt = saltHex.trim(); + if (!/^[a-f0-9]{32}$/i.test(normalizedSalt)) return false; + + const computedHash = deriveApiKeyHash(raw, normalizedSalt); + return safeHashEquals(expectedHashHex, computedHash); } diff --git a/src/modules/api-keys/api-keys.service.ts b/src/modules/api-keys/api-keys.service.ts index 95679f4..e66e975 100644 --- a/src/modules/api-keys/api-keys.service.ts +++ b/src/modules/api-keys/api-keys.service.ts @@ -7,10 +7,10 @@ import { generateRawApiKey, getKeyPrefix, getKeyPreview, hashApiKey } from "./ap export const apiKeysService = { async createKey(request: FastifyRequest, body: ApiKeyCreateBody): Promise { const raw = generateRawApiKey(); - const keyHash = hashApiKey(raw); + const { hash: keyHash, salt: keySalt } = hashApiKey(raw); const keyPrefix = getKeyPrefix(raw); - const record = await apiKeysRepository.create(request, body, keyHash, keyPrefix); + const record = await apiKeysRepository.create(request, body, keyHash, keySalt, keyPrefix); return { key: raw, diff --git a/supabase/migrations/20260413000200_api_keys_per_key_salt.sql b/supabase/migrations/20260413000200_api_keys_per_key_salt.sql new file mode 100644 index 0000000..5453bf7 --- /dev/null +++ b/supabase/migrations/20260413000200_api_keys_per_key_salt.sql @@ -0,0 +1,24 @@ +-- API keys hardening: move to per-key random salt (scrypt) verification. +-- Existing keys were hashed without per-key salt and cannot be re-derived safely. +-- We revoke legacy active keys so operators can rotate them. + +alter table public.api_keys + add column if not exists key_salt text; + +update public.api_keys +set active = false, + revoked_at = coalesce(revoked_at, now()) +where key_salt is null + and active = true; + +-- Keep column non-null for all future keys while preserving deterministic behavior. +update public.api_keys +set key_salt = coalesce(key_salt, 'legacy_revoked') +where key_salt is null; + +alter table public.api_keys + alter column key_salt set not null; + +create index if not exists idx_api_keys_prefix_active + on public.api_keys(key_prefix, active) + where revoked_at is null;