From d44a6f699964e235eaba4913673476a93bc2bd40 Mon Sep 17 00:00:00 2001 From: "Cyne Jarvis J. Zarceno" <165563006+Kuhai9801@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:50:10 +0800 Subject: [PATCH 1/2] feat(security): enforce API key auth on protected routes --- .env.example | 3 + README.md | 18 ++- docs/CONFIGURATION.md | 7 + docs/SECURITY.md | 21 ++- drizzle/0001_add_streams_deleted_at.sql | 2 + drizzle/meta/_journal.json | 7 + jest.config.js | 6 +- src/api/v1/streams.ts | 67 +++++----- src/apiKeyAuth.test.ts | 166 +++++++++++++++++++++++- src/config/env.ts | 29 ++++- src/db/schema.ts | 21 ++- src/index.ts | 37 +++--- src/indexerWebhook.test.ts | 87 ++++++++++++- src/middleware/apiKeyAuth.ts | 127 ++++++++++-------- src/repositories/streamRepository.ts | 31 +++-- src/routes/webhooks/indexer.ts | 75 ++++++----- tsconfig.json | 2 +- 17 files changed, 530 insertions(+), 176 deletions(-) create mode 100644 drizzle/0001_add_streams_deleted_at.sql diff --git a/.env.example b/.env.example index d3f158a..704c5a7 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,9 @@ JWT_SECRET=your_super_secret_jwt_key_at_least_32_chars_long # --- RPC / Chain --- RPC_URL=https://api.mainnet-beta.solana.com +# Optional deep health probe controls. +# HEALTH_CHECK_TIMEOUT_MS=5000 +# RPC_PROBE_ENABLED=false # --- Webhooks --- # INDEXER_WEBHOOK_SECRET= diff --git a/README.md b/README.md index b961dbc..a7b8c90 100644 --- a/README.md +++ b/README.md @@ -70,15 +70,26 @@ The backend supports API key authentication for internal jobs and partner integr - Header: `x-api-key` or `Authorization: ApiKey ` - Keys are hashed with SHA-256 at rest -- Constant-time comparison via `crypto.timingSafeEqual` +- Constant-time digest comparison via `crypto.timingSafeEqual` - Revoked keys are rejected and treated as invalid Set environment variable(s) before starting: - `API_KEYS`: comma-separated plaintext keys (development/test only) -- `API_KEY_HASHES`: comma-separated SHA256 hashes (production / at-rest hashes) +- `API_KEY_HASHES`: comma-separated SHA-256 hashes (production / at-rest hashes) -Add `x-api-key` to `/api/v1/*` and `/webhooks/indexer` requests. +Required route scope: + +| Route | Methods | Required headers | +|-------|---------|------------------| +| `/api/v1/streams` | `GET` | `x-api-key` or `Authorization: ApiKey ` | +| `/api/v1/streams/:id` | `GET`, `PATCH` | `x-api-key` or `Authorization: ApiKey ` | +| `/api/v1/streams/:id/accrual-preview` | `GET` | `x-api-key` or `Authorization: ApiKey ` | +| `/webhooks/indexer` | `POST` only | `x-api-key` or `Authorization: ApiKey `, plus `x-indexer-signature` | + +API key authentication runs before JSON body parsing on `/api/v1/*` and before raw-body parsing on `POST /webhooks/indexer`, so unauthenticated requests do not reach validation, repository calls, or HMAC verification. Missing keys return `401` with `{ "error": "API key missing" }`; invalid or revoked keys return `401` with `{ "error": "API key invalid or revoked" }`. + +Public routes that do not require API key authentication are `GET /health` and `GET /api/openapi.json`. ## Indexer webhook ingestion @@ -105,6 +116,7 @@ Example payload: Security notes: - Signature verification uses the raw request body and `crypto.timingSafeEqual`. +- API key authentication is enforced before raw-body parsing and HMAC verification. - Replay protection is enforced by deduplicating `eventId` values in the ingestion service. - Duplicate deliveries are treated as safe no-ops and return `202 Accepted`. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 67435a9..25ab697 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -26,6 +26,13 @@ startup. Defaults shown are applied when the variable is unset. | `DB_CONNECTION_TIMEOUT` | `5000` / `10000` | Connection acquisition timeout in ms. | | `DB_STATEMENT_TIMEOUT` | `30000` / `60000` | Per-statement timeout in ms. | +## Health checks + +| Variable | Default | Description | +|----------|---------|-------------| +| `HEALTH_CHECK_TIMEOUT_MS` | `5000` | Timeout for deep health probes. | +| `RPC_PROBE_ENABLED` | `false` | When true, deep health checks also probe the configured RPC endpoint. Accepts `true`/`false`, `1`/`0`, `yes`/`no`, or `on`/`off`. | + ## Rate limiting | Variable | Default | Description | diff --git a/docs/SECURITY.md b/docs/SECURITY.md index f27bfd7..6e49c0a 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -14,7 +14,8 @@ StreamPay Backend handles partner integrations and chain-indexer webhooks. Key security controls include: - **API key authentication** with SHA-256 hashes at rest and constant-time - comparison (`crypto.timingSafeEqual`). + digest comparison (`crypto.timingSafeEqual`). The middleware is enforced + before JSON/raw-body parsing for protected routes. - **HMAC verification** of indexer webhook payloads against the raw request body, using `INDEXER_WEBHOOK_SECRET`. - **Replay protection** via deduplication of `eventId` values in the @@ -22,6 +23,24 @@ Key security controls include: - **IP-based and API-key-based rate limiting** through `express-rate-limit`. - **Strict CORS** allowlists in production (no wildcard). +## API Key Enforcement Scope + +All routes under `/api/v1/*` require either `x-api-key` or +`Authorization: ApiKey `. Current protected routes are +`GET /api/v1/streams`, `GET /api/v1/streams/:id`, +`GET /api/v1/streams/:id/accrual-preview`, and +`PATCH /api/v1/streams/:id`. This middleware runs before route handlers and +before JSON body parsing, so unauthenticated requests do not reach validation, +repository, or mutating handlers. + +`POST /webhooks/indexer` requires both API key authentication and the existing +`x-indexer-signature` HMAC signature. API key authentication runs first, then +the raw JSON body is parsed and passed to HMAC verification. The API key layer is +an additional control and does not replace HMAC verification. + +Public operational routes remain unauthenticated: `GET /health` and +`GET /api/openapi.json`. + ## Dependency Hygiene - Dependabot is configured to open weekly PRs for npm updates. diff --git a/drizzle/0001_add_streams_deleted_at.sql b/drizzle/0001_add_streams_deleted_at.sql new file mode 100644 index 0000000..c823fd4 --- /dev/null +++ b/drizzle/0001_add_streams_deleted_at.sql @@ -0,0 +1,2 @@ +ALTER TABLE "streams" ADD COLUMN IF NOT EXISTS "deleted_at" timestamp; +CREATE INDEX IF NOT EXISTS "streams_deleted_at_idx" ON "streams" ("deleted_at"); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 4a96783..17d4fae 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1743350400000, "tag": "0000_init", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1781678686511, + "tag": "0001_add_streams_deleted_at", + "breakpoints": true } ] } diff --git a/jest.config.js b/jest.config.js index da90367..6706e73 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,7 +2,11 @@ module.exports = { preset: "ts-jest", testEnvironment: "node", - testMatch: ["**/__tests__/**/*.test.ts"], + testMatch: [ + "/src/**/__tests__/**/*.test.ts", + "/src/apiKeyAuth.test.ts", + "/src/indexerWebhook.test.ts", + ], collectCoverageFrom: [ "src/cache/**/*.ts", "src/services/**/*.ts", diff --git a/src/api/v1/streams.ts b/src/api/v1/streams.ts index 52102c6..0268e69 100644 --- a/src/api/v1/streams.ts +++ b/src/api/v1/streams.ts @@ -1,6 +1,11 @@ -import { Router, Request, Response } from "express"; -import { StreamRepository } from "../../repositories/streamRepository"; +import { Request, Response, Router } from "express"; import { validate } from "../../middleware/validate"; +import { + FindAllParams, + StreamRepository, + UpdateStreamParams, +} from "../../repositories/streamRepository"; +import { accrualService } from "../../services/accrualService"; import { getStreamsQuerySchema, uuidParamSchema, @@ -8,23 +13,17 @@ import { const router = Router(); const streamRepository = new StreamRepository(); -const auditService = new AuditService(); const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const allowedUpdateFields = new Set(["labels", "offChainMemo", "status", "updatedAt"]); +const validStreamStatuses = ["active", "paused", "cancelled", "completed"] as const; -const getBearerToken = (authorizationHeader?: string): string | null => { - if (!authorizationHeader) return null; - const [scheme, token] = authorizationHeader.split(" "); - if (scheme !== "Bearer" || !token) return null; - return token; +type UpdateStreamRequestBody = Omit, "updatedAt"> & { + updatedAt?: string; }; -const isProtectedActionAuthorized = (req: Request): boolean => { - const expected = process.env.JWT_SECRET; - if (!expected) return false; - - const token = getBearerToken(req.header("authorization")); - return token === expected; +const isJsonObject = (value: unknown): value is Record => { + return typeof value === "object" && value !== null && !Array.isArray(value); }; // GET /api/v1/streams/:id/accrual-preview @@ -32,8 +31,6 @@ router.get("/:id/accrual-preview", async (req: Request, res: Response) => { try { const { id } = req.params; - // UUID validation - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; if (!uuidRegex.test(id)) { return res.status(400).json({ error: "Invalid stream ID format" }); } @@ -83,53 +80,58 @@ router.get( router.patch("/:id", async (req: Request, res: Response) => { try { const { id } = req.params; - const updates = req.body as Partial & { updatedAt?: string }; + const requestBody = req.body ?? {}; if (!uuidRegex.test(id)) { return res.status(400).json({ error: "Invalid stream ID format" }); } - // Validate writable fields whitelist - const allowedFields = ["labels", "offChainMemo", "status", "updatedAt"]; - const invalidFields = Object.keys(updates).filter(field => !allowedFields.includes(field)); + if (!isJsonObject(requestBody)) { + return res.status(400).json({ error: "Request body must be a JSON object" }); + } + + const updates = requestBody as UpdateStreamRequestBody; + const invalidFields = Object.keys(updates).filter((field) => !allowedUpdateFields.has(field)); if (invalidFields.length > 0) { return res.status(400).json({ error: `Invalid fields: ${invalidFields.join(", ")}` }); } - // Validate status if provided - if (updates.status && !["active", "paused", "cancelled", "completed"].includes(updates.status)) { + if (updates.status && !validStreamStatuses.includes(updates.status)) { return res.status(400).json({ error: "Invalid status value" }); } - // Validate labels if provided (should be array of strings) - if (updates.labels !== undefined && (!Array.isArray(updates.labels) || !updates.labels.every(label => typeof label === "string"))) { + if ( + updates.labels !== undefined && + (!Array.isArray(updates.labels) || !updates.labels.every((label) => typeof label === "string")) + ) { return res.status(400).json({ error: "Labels must be an array of strings" }); } - // Validate offChainMemo if provided (should be string or null) if (updates.offChainMemo !== undefined && updates.offChainMemo !== null && typeof updates.offChainMemo !== "string") { return res.status(400).json({ error: "offChainMemo must be a string or null" }); } - // Parse updatedAt if provided for optimistic locking let currentUpdatedAt: Date | undefined; if (updates.updatedAt) { currentUpdatedAt = new Date(updates.updatedAt); - if (isNaN(currentUpdatedAt.getTime())) { + if (Number.isNaN(currentUpdatedAt.getTime())) { return res.status(400).json({ error: "Invalid updatedAt format" }); } - delete updates.updatedAt; // Remove from updates as it's for locking } - const updatedStream = await streamRepository.updateById(id, updates as UpdateStreamParams, currentUpdatedAt); + const repositoryUpdates: UpdateStreamParams = {}; + if (updates.labels !== undefined) repositoryUpdates.labels = updates.labels; + if (updates.offChainMemo !== undefined) repositoryUpdates.offChainMemo = updates.offChainMemo; + if (updates.status !== undefined) repositoryUpdates.status = updates.status; + + const updatedStream = await streamRepository.updateById(id, repositoryUpdates, currentUpdatedAt); if (!updatedStream) { return res.status(404).json({ error: "Stream not found or update conflict" }); } - // Return the updated stream with accruedEstimate const streamWithEstimate = await streamRepository.findById(id); - res.json(streamWithEstimate); + res.json(streamWithEstimate ?? updatedStream); } catch (error) { console.error("Error updating stream:", error); res.status(500).json({ error: "Internal server error" }); @@ -142,8 +144,7 @@ router.get( validate({ query: getStreamsQuerySchema }), async (req: Request, res: Response) => { try { - const params = req.query; - + const params = req.query as unknown as FindAllParams; const result = await streamRepository.findAll(params); res.json(result); diff --git a/src/apiKeyAuth.test.ts b/src/apiKeyAuth.test.ts index e78b19d..f587de5 100644 --- a/src/apiKeyAuth.test.ts +++ b/src/apiKeyAuth.test.ts @@ -1,31 +1,107 @@ +import crypto from "crypto"; import request from "supertest"; import app from "./index"; -import { apiKeyStore, refreshApiKeyStore, hashApiKey, ApiKeyStore } from "./middleware/apiKeyAuth"; +import { apiKeyStore, ApiKeyStore, hashApiKey, refreshApiKeyStore } from "./middleware/apiKeyAuth"; import { StreamRepository } from "./repositories/streamRepository"; -import crypto from "crypto"; describe("API key authentication", () => { + let findAllSpy: jest.SpyInstance; + beforeEach(() => { - process.env.API_KEYS = "valid-service-key"; + process.env.API_KEYS = "valid-service-key,secondary-service-key"; refreshApiKeyStore(); - jest.spyOn(StreamRepository.prototype, "findAll").mockResolvedValue({ streams: [], total: 0, limit: 20, offset: 0 }); + findAllSpy = jest + .spyOn(StreamRepository.prototype, "findAll") + .mockResolvedValue({ streams: [], total: 0, limit: 20, offset: 0 }); }); afterEach(() => { apiKeyStore.clear(); delete process.env.API_KEYS; + delete process.env.API_KEY_HASHES; jest.restoreAllMocks(); }); + it("leaves public operational routes unauthenticated", async () => { + const health = await request(app).get("/health"); + expect(health.status).toBe(200); + + const openApi = await request(app).get("/api/openapi.json"); + expect(openApi.status).toBe(200); + }); + it("accepts a valid key from x-api-key header", async () => { const res = await request(app).get("/api/v1/streams").set("x-api-key", "valid-service-key"); + + expect(res.status).toBe(200); + expect(findAllSpy).toHaveBeenCalledTimes(1); + }); + + it("accepts a valid key from Authorization ApiKey header", async () => { + const res = await request(app).get("/api/v1/streams").set("Authorization", "ApiKey secondary-service-key"); + + expect(res.status).toBe(200); + expect(findAllSpy).toHaveBeenCalledTimes(1); + }); + + it("accepts Authorization ApiKey scheme case-insensitively", async () => { + const res = await request(app).get("/api/v1/streams").set("Authorization", "apikey secondary-service-key"); + expect(res.status).toBe(200); + expect(findAllSpy).toHaveBeenCalledTimes(1); }); - it("rejects a missing key with 401", async () => { + it("accepts keys seeded from API_KEY_HASHES", async () => { + process.env.API_KEYS = ""; + process.env.API_KEY_HASHES = hashApiKey("hashed-service-key"); + refreshApiKeyStore(); + + const res = await request(app).get("/api/v1/streams").set("x-api-key", "hashed-service-key"); + + expect(res.status).toBe(200); + expect(findAllSpy).toHaveBeenCalledTimes(1); + }); + + it("normalizes uppercase hashes seeded from API_KEY_HASHES", async () => { + process.env.API_KEYS = ""; + process.env.API_KEY_HASHES = hashApiKey("uppercase-hash-key").toUpperCase(); + refreshApiKeyStore(); + + const res = await request(app).get("/api/v1/streams").set("x-api-key", "uppercase-hash-key"); + + expect(res.status).toBe(200); + expect(findAllSpy).toHaveBeenCalledTimes(1); + }); + + it("rejects malformed API_KEY_HASHES during store refresh", () => { + process.env.API_KEYS = ""; + process.env.API_KEY_HASHES = "not-a-sha256-hex-digest"; + + expect(() => refreshApiKeyStore()).toThrow("SHA-256 hex hashes"); + }); + + it("rejects a missing key with 401 before protected handlers run", async () => { const res = await request(app).get("/api/v1/streams"); + expect(res.status).toBe(401); expect(res.body).toEqual({ error: "API key missing" }); + expect(findAllSpy).not.toHaveBeenCalled(); + }); + + it("rejects unsupported Authorization schemes as a missing API key", async () => { + const res = await request(app).get("/api/v1/streams").set("Authorization", "Bearer not-an-api-key"); + + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "API key missing" }); + expect(findAllSpy).not.toHaveBeenCalled(); + }); + + it("rejects an invalid key", async () => { + const res = await request(app).get("/api/v1/streams").set("x-api-key", "wrong-service-key"); + + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "API key invalid or revoked" }); + expect(findAllSpy).not.toHaveBeenCalled(); }); it("rejects a revoked key", async () => { @@ -36,8 +112,53 @@ describe("API key authentication", () => { } const res = await request(app).get("/api/v1/streams").set("x-api-key", "valid-service-key"); + expect(res.status).toBe(401); expect(res.body).toEqual({ error: "API key invalid or revoked" }); + expect(findAllSpy).not.toHaveBeenCalled(); + }); + + it("protects mutating API v1 routes before request validation", async () => { + const res = await request(app) + .patch("/api/v1/streams/not-a-uuid") + .send({ status: "paused" }); + + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "API key missing" }); + }); + + it("rejects missing API keys before parsing malformed JSON", async () => { + const res = await request(app) + .patch("/api/v1/streams/550e8400-e29b-41d4-a716-446655440000") + .set("Content-Type", "application/json") + .send('{"status":'); + + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "API key missing" }); + }); + + it("returns JSON parse errors only after API key auth succeeds", async () => { + const res = await request(app) + .patch("/api/v1/streams/550e8400-e29b-41d4-a716-446655440000") + .set("x-api-key", "valid-service-key") + .set("Content-Type", "application/json") + .send('{"status":'); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ + error: "invalid_json", + message: "Request body must be valid JSON.", + }); + }); + + it("rejects non-object PATCH bodies after API key auth succeeds", async () => { + const res = await request(app) + .patch("/api/v1/streams/550e8400-e29b-41d4-a716-446655440000") + .set("x-api-key", "valid-service-key") + .send(["not", "an", "object"]); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: "Request body must be a JSON object" }); }); it("uses timingSafeEqual in validation", async () => { @@ -46,7 +167,6 @@ describe("API key authentication", () => { expect(res.status).toBe(200); expect(timingSpy).toHaveBeenCalled(); - timingSpy.mockRestore(); }); it("in-memory store can add and resolve hashed keys", () => { @@ -58,4 +178,38 @@ describe("API key authentication", () => { expect(isValid?.id).toBe("auth-1"); expect(isValid?.hash).toBe(hashApiKey("secret-value")); }); + + it("scans all configured records without short-circuiting on the first match", () => { + const store = new ApiKeyStore(); + store.addPlaintextKey("auth-1", "secret-value"); + store.addPlaintextKey("auth-2", "rotated-secret", true); + const timingSpy = jest.spyOn(crypto, "timingSafeEqual"); + + const isValid = store.findKeyByValue("secret-value"); + + expect(isValid?.id).toBe("auth-1"); + expect(timingSpy).toHaveBeenCalledTimes(2); + }); + + it("returns defensive copies of key records", () => { + const store = new ApiKeyStore(); + const created = store.addPlaintextKey("auth-1", "secret-value"); + created.revoked = true; + + const resolved = store.findKeyByValue("secret-value"); + expect(resolved?.id).toBe("auth-1"); + + if (resolved) { + resolved.revoked = true; + } + expect(store.findKeyByValue("secret-value")?.id).toBe("auth-1"); + }); + + it("rejects malformed hash records", () => { + const store = new ApiKeyStore(); + + expect(() => { + store.addKeyRecord({ id: "bad-hash", hash: "not-a-sha256-hex-digest", revoked: false }); + }).toThrow("SHA-256 hex digest"); + }); }); diff --git a/src/config/env.ts b/src/config/env.ts index 2f8d71b..e9740e5 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -5,12 +5,31 @@ import dotenv from "dotenv"; // callers importing this module get the fully merged environment. dotenv.config(); +const booleanFromEnv = z.preprocess((value: unknown) => { + if (typeof value !== "string") { + return value; + } + + const normalized = value.trim().toLowerCase(); + + if (["1", "true", "yes", "on"].includes(normalized)) { + return true; + } + + if (["0", "false", "no", "off", ""].includes(normalized)) { + return false; + } + + return value; +}, z.boolean()); + /** * Runtime schema for every environment variable the service consumes. * - * Coercion is intentional: process.env values are always strings, but most - * fields are semantically numeric. The schema centralizes defaults so the - * rest of the codebase can treat `Env` as a plain typed config object. + * Coercion is intentional for numeric fields: process.env values are always + * strings, but most fields are semantically numeric. The schema centralizes + * defaults so the rest of the codebase can treat `Env` as a plain typed config + * object. */ export const envSchema = z.object({ PORT: z.coerce.number().default(3001), @@ -29,6 +48,8 @@ export const envSchema = z.object({ DB_POOL_IDLE_TIMEOUT: z.coerce.number().min(0).default(30000), DB_CONNECTION_TIMEOUT: z.coerce.number().min(0).default(5000), DB_STATEMENT_TIMEOUT: z.coerce.number().min(0).default(30000), + HEALTH_CHECK_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), + RPC_PROBE_ENABLED: booleanFromEnv.default(false), }); export type Env = z.infer; @@ -59,5 +80,7 @@ export const env = process.env.NODE_ENV === "test" DB_POOL_IDLE_TIMEOUT: 10000, DB_CONNECTION_TIMEOUT: 2000, DB_STATEMENT_TIMEOUT: 10000, + HEALTH_CHECK_TIMEOUT_MS: 5000, + RPC_PROBE_ENABLED: false, }) : validateEnv(process.env); diff --git a/src/db/schema.ts b/src/db/schema.ts index 2223481..7774837 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,17 @@ -import { pgTable, uuid, varchar, timestamp, decimal, pgEnum, json, text } from "drizzle-orm/pg-core"; +import { + boolean, + decimal, + index, + integer, + json, + jsonb, + pgEnum, + pgTable, + text, + timestamp, + uuid, + varchar, +} from "drizzle-orm/pg-core"; export const streamStatusEnum = pgEnum("stream_status", ["active", "paused", "cancelled", "completed"]); export const auditActionEnum = pgEnum("audit_action", ["stream_create", "stream_update", "stream_admin_action"]); @@ -17,6 +30,7 @@ export const streams = pgTable("streams", { offChainMemo: text("off_chain_memo"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), + deletedAt: timestamp("deleted_at"), chainId: varchar("chain_id", { length: 50 }).notNull().default("stellar-testnet"), contractAddress: varchar("contract_address", { length: 255 }), transactionHash: varchar("transaction_hash", { length: 66 }), @@ -27,6 +41,7 @@ export const streams = pgTable("streams", { statusIdx: index("streams_status_idx").on(table.status), chainIdIdx: index("streams_chain_id_idx").on(table.chainId), createdAtIdx: index("streams_created_at_idx").on(table.createdAt), + deletedAtIdx: index("streams_deleted_at_idx").on(table.deletedAt), })); export const auditLogs = pgTable("audit_logs", { @@ -41,6 +56,8 @@ export const auditLogs = pgTable("audit_logs", { export type Stream = typeof streams.$inferSelect; export type NewStream = typeof streams.$inferInsert; +export type AuditLog = typeof auditLogs.$inferSelect; +export type NewAuditLog = typeof auditLogs.$inferInsert; // --------------------------------------------------------------------------- // Outbound webhook subscriptions @@ -64,7 +81,7 @@ export const webhookSubscriptions = pgTable("webhook_subscriptions", { id: uuid("id").primaryKey().defaultRandom(), /** Caller-supplied URL to POST events to. */ url: varchar("url", { length: 2048 }).notNull(), - /** HMAC-SHA256 signing secret — stored hashed, never returned in API responses. */ + /** Sensitive HMAC-SHA256 signing secret used for outbound delivery signing. */ secret: varchar("secret", { length: 255 }).notNull(), /** Comma-separated list of event types to deliver; empty = all events. */ eventTypes: text("event_types").notNull().default(""), diff --git a/src/index.ts b/src/index.ts index 5a2e69d..ac3ac3b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,33 +3,23 @@ */ import cors from "cors"; -import express, { Request, Response } from "express"; +import express, { ErrorRequestHandler, NextFunction, Request, Response } from "express"; import streamRoutes from "./api/v1/streams"; import { generateOpenApi } from "./api/v1/openapi"; -import { metricsHandler, metricsMiddleware } from "./metrics/prometheus"; - -import indexerWebhookRouter from "./routes/webhooks/indexer"; -import { metricsHandler, metricsMiddleware } from "./metrics/prometheus"; - -import { metricsHandler, metricsMiddleware } from "./metrics/prometheus"; - import { env } from "./config/env"; -import { metricsHandler, metricsMiddleware } from "./metrics/prometheus"; +import { apiKeyAuthMiddleware } from "./middleware/apiKeyAuth"; +import { webhookRepository } from "./repositories/webhookRepository"; +import indexerWebhookRouter from "./routes/webhooks/indexer"; +import { WebhookDeliveryService } from "./services/webhookDeliveryService"; export const JSON_BODY_LIMIT = "100kb"; export const JSON_BODY_LIMIT_BYTES = 100 * 1024; -export const MAX_HEADER_SIZE_BYTES = 16 * 1024; const payloadTooLargeResponse = { error: "payload_too_large", message: `JSON request body exceeds ${JSON_BODY_LIMIT} limit.`, }; -const headersTooLargeResponse = { - error: "headers_too_large", - message: `Request headers exceed ${MAX_HEADER_SIZE_BYTES} byte limit.`, -}; - export const rejectOversizedJsonPayload = (req: Request, res: Response, next: NextFunction) => { if (!req.is("application/json")) { next(); @@ -74,12 +64,8 @@ const app = express(); const PORT = env.PORT; app.use(cors()); -app.use( - "/webhooks/indexer", - express.raw({ type: "application/json" }), - indexerWebhookRouter, -); -app.use(express.json()); + +app.use("/webhooks/indexer", indexerWebhookRouter); app.get("/health", (_req: Request, res: Response) => { res.json({ @@ -93,8 +79,17 @@ app.get("/api/openapi.json", (_req: Request, res: Response) => { res.json(generateOpenApi()); }); +app.use( + "/api/v1", + apiKeyAuthMiddleware, + rejectOversizedJsonPayload, + express.json({ limit: JSON_BODY_LIMIT }), +); + app.use("/api/v1/streams", streamRoutes); +app.use(httpBodyErrorHandler); + /* istanbul ignore next */ if (require.main === module) { const deliveryService = new WebhookDeliveryService(webhookRepository); diff --git a/src/indexerWebhook.test.ts b/src/indexerWebhook.test.ts index 06ae116..099b895 100644 --- a/src/indexerWebhook.test.ts +++ b/src/indexerWebhook.test.ts @@ -2,8 +2,8 @@ import crypto from "crypto"; import request from "supertest"; import app from "./index"; +import { apiKeyStore, refreshApiKeyStore } from "./middleware/apiKeyAuth"; import { eventIngestionService } from "./services/eventIngestionService"; -import { refreshApiKeyStore } from "./middleware/apiKeyAuth"; const secret = "test-indexer-secret"; @@ -32,10 +32,12 @@ describe("POST /webhooks/indexer", () => { eventIngestionService.reset(); }); - afterAll(() => { + afterEach(() => { + jest.restoreAllMocks(); + eventIngestionService.reset(); + apiKeyStore.clear(); delete process.env.INDEXER_WEBHOOK_SECRET; delete process.env.API_KEYS; - eventIngestionService.reset(); }); it("accepts a valid signed event", async () => { @@ -57,7 +59,78 @@ describe("POST /webhooks/indexer", () => { }); }); - it("rejects an invalid signature", async () => { + it("accepts a valid API key from Authorization ApiKey header", async () => { + const body = JSON.stringify({ ...payload, eventId: "evt_authorization_header" }); + + const res = await request(app) + .post("/webhooks/indexer") + .set("Content-Type", "application/json") + .set("Authorization", "ApiKey test-1234") + .set("x-indexer-signature", sign(body)) + .send(body); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + accepted: true, + duplicate: false, + eventId: "evt_authorization_header", + }); + }); + + it("rejects a missing API key before raw-body parsing and HMAC verification", async () => { + const body = JSON.stringify(payload); + const ingestSpy = jest.spyOn(eventIngestionService, "ingest"); + + const res = await request(app) + .post("/webhooks/indexer") + .set("Content-Type", "application/json") + .set("x-indexer-signature", sign(body)) + .send(body); + + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "API key missing" }); + expect(ingestSpy).not.toHaveBeenCalled(); + }); + + it("rejects an invalid API key before HMAC verification", async () => { + const body = JSON.stringify(payload); + const ingestSpy = jest.spyOn(eventIngestionService, "ingest"); + + const res = await request(app) + .post("/webhooks/indexer") + .set("Content-Type", "application/json") + .set("x-api-key", "wrong-key") + .set("x-indexer-signature", sign(body)) + .send(body); + + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "API key invalid or revoked" }); + expect(ingestSpy).not.toHaveBeenCalled(); + }); + + it("rejects a revoked API key before HMAC verification", async () => { + const record = apiKeyStore.findKeyByValue("test-1234"); + expect(record).not.toBeNull(); + if (record) { + apiKeyStore.revokeKey(record.id); + } + + const body = JSON.stringify(payload); + const ingestSpy = jest.spyOn(eventIngestionService, "ingest"); + + const res = await request(app) + .post("/webhooks/indexer") + .set("Content-Type", "application/json") + .set("x-api-key", "test-1234") + .set("x-indexer-signature", sign(body)) + .send(body); + + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "API key invalid or revoked" }); + expect(ingestSpy).not.toHaveBeenCalled(); + }); + + it("rejects an invalid signature after accepting API key auth", async () => { const body = JSON.stringify(payload); const res = await request(app) @@ -168,4 +241,10 @@ describe("POST /webhooks/indexer", () => { error: "invalid_body", }); }); + + it("does not apply POST webhook API key auth to unsupported webhook methods", async () => { + const res = await request(app).get("/webhooks/indexer"); + + expect(res.status).toBe(404); + }); }); diff --git a/src/middleware/apiKeyAuth.ts b/src/middleware/apiKeyAuth.ts index 92b1904..87f5f0e 100644 --- a/src/middleware/apiKeyAuth.ts +++ b/src/middleware/apiKeyAuth.ts @@ -1,5 +1,8 @@ import crypto from "crypto"; -import { Request, Response, NextFunction } from "express"; +import { NextFunction, Request, Response } from "express"; + +const SHA256_HEX_REGEX = /^[0-9a-f]{64}$/i; +const AUTHORIZATION_API_KEY_REGEX = /^ApiKey\s+(.+)$/i; declare module "express" { interface Request { @@ -13,7 +16,8 @@ declare module "express" { * Persisted representation of an API key. * * The plaintext key is never stored — only its SHA-256 hash. Revoked records - * are kept so that lookups remain constant-time even after rotation. + * stay in the store so validation performs digest comparison work for active + * and rotated-out keys while still rejecting revoked matches. */ export interface ApiKeyRecord { /** Stable identifier exposed to operators (e.g. for audit logs). */ @@ -24,14 +28,26 @@ export interface ApiKeyRecord { revoked: boolean; } +type StoredApiKeyRecord = { + record: ApiKeyRecord; + hashBuffer: Buffer; +}; + +const copyApiKeyRecord = (record: ApiKeyRecord): ApiKeyRecord => ({ ...record }); +const normalizeHash = (hash: string): string => hash.toLowerCase(); + /** - * In-memory store of {@link ApiKeyRecord} entries keyed by SHA-256 hash. + * In-memory store of {@link ApiKeyRecord} entries keyed by stable key id. * - * Designed for constant-time equality checks via `crypto.timingSafeEqual` to - * avoid leaking key material through response-time side channels. + * Requests are checked by hashing the candidate key and comparing that digest + * against every configured record with `crypto.timingSafeEqual`. Stored digests + * are validated and pre-decoded to `Buffer`s when records are added, avoiding + * repeated hex parsing on every request. The scan intentionally does not + * short-circuit on a match, which avoids leaking which key id matched and keeps + * revoked records on the same comparison path as active records. */ export class ApiKeyStore { - private keys = new Map(); + private readonly keys = new Map(); constructor(initialKeys: ApiKeyRecord[] = []) { for (const key of initialKeys) { @@ -40,24 +56,39 @@ export class ApiKeyStore { } addKeyRecord(record: ApiKeyRecord): void { - if (!record || !record.id || !record.hash) { + if (!record || !record.id || !record.hash || typeof record.revoked !== "boolean") { throw new Error("ApiKeyStore: invalid key record"); } - this.keys.set(record.id, record); + + if (!SHA256_HEX_REGEX.test(record.hash)) { + throw new Error("ApiKeyStore: key hash must be a SHA-256 hex digest"); + } + + const normalizedRecord = copyApiKeyRecord({ + ...record, + hash: normalizeHash(record.hash), + }); + + this.keys.set(normalizedRecord.id, { + record: normalizedRecord, + hashBuffer: Buffer.from(normalizedRecord.hash, "hex"), + }); } addPlaintextKey(id: string, apiKey: string, revoked = false): ApiKeyRecord { - const hash = hashApiKey(apiKey); - const record: ApiKeyRecord = { id, hash, revoked }; + const record: ApiKeyRecord = { id, hash: hashApiKey(apiKey), revoked }; this.addKeyRecord(record); - return record; + return copyApiKeyRecord(record); } revokeKey(id: string): void { - const record = this.keys.get(id); - if (!record) return; - record.revoked = true; - this.keys.set(id, record); + const entry = this.keys.get(id); + if (!entry) return; + + this.keys.set(id, { + ...entry, + record: { ...entry.record, revoked: true }, + }); } clear(): void { @@ -65,46 +96,41 @@ export class ApiKeyStore { } getKeys(): ApiKeyRecord[] { - return Array.from(this.keys.values()); + return Array.from(this.keys.values(), ({ record }) => copyApiKeyRecord(record)); } findKeyByValue(apiKey: string): ApiKeyRecord | null { - const candidateHash = hashApiKey(apiKey); - const candidateBuffer = Buffer.from(candidateHash, "hex"); + const candidateBuffer = Buffer.from(hashApiKey(apiKey), "hex"); + let matchedRecord: ApiKeyRecord | null = null; - for (const record of this.keys.values()) { - if (record.revoked) continue; + for (const { record, hashBuffer } of this.keys.values()) { + const isMatch = crypto.timingSafeEqual(candidateBuffer, hashBuffer); - const storedBuffer = Buffer.from(record.hash, "hex"); - - if (storedBuffer.length !== candidateBuffer.length) { - continue; - } - - if (crypto.timingSafeEqual(candidateBuffer, storedBuffer)) { - return record; + if (isMatch && !record.revoked) { + matchedRecord = record; } } - return null; + return matchedRecord ? copyApiKeyRecord(matchedRecord) : null; } static fromEnv(): ApiKeyStore { const store = new ApiKeyStore(); - const plaintextValues = process.env.API_KEYS?.split(",").map((v) => v.trim()).filter(Boolean) ?? []; + const plaintextValues = process.env.API_KEYS?.split(",").map((value) => value.trim()).filter(Boolean) ?? []; for (const [index, apiKey] of plaintextValues.entries()) { store.addPlaintextKey(`env-${index}`, apiKey); } - const hashedValues = process.env.API_KEY_HASHES?.split(",").map((v) => v.trim()).filter(Boolean) ?? []; + const hashedValues = process.env.API_KEY_HASHES?.split(",").map((value) => value.trim()).filter(Boolean) ?? []; for (const [index, hash] of hashedValues.entries()) { - if (!/^[0-9a-f]{64}$/i.test(hash)) { - throw new Error("API_KEY_HASHES must be a comma-separated list of SHA256 hex hashes"); + if (!SHA256_HEX_REGEX.test(hash)) { + throw new Error("API_KEY_HASHES must be a comma-separated list of SHA-256 hex hashes"); } - store.addKeyRecord({ id: `env-hash-${index}`, hash: hash.toLowerCase(), revoked: false }); + + store.addKeyRecord({ id: `env-hash-${index}`, hash, revoked: false }); } return store; @@ -115,8 +141,8 @@ export const hashApiKey = (apiKey: string): string => { if (!apiKey) { throw new Error("API key is required for hashing"); } - const hash = crypto.createHash("sha256").update(apiKey, "utf-8").digest("hex"); - return hash; + + return crypto.createHash("sha256").update(apiKey, "utf-8").digest("hex"); }; // Shared store; tests can reset. @@ -130,26 +156,23 @@ export const refreshApiKeyStore = (): void => { } }; -const parseAuthorization = (headerValue: string): string | null => { - const trimmed = headerValue.trim(); - if (!trimmed) return null; +const parseAuthorizationApiKey = (headerValue: string): string | null => { + const match = headerValue.trim().match(AUTHORIZATION_API_KEY_REGEX); + return match ? match[1].trim() : null; +}; - const bearerMatch = trimmed.match(/^ApiKey\s+(.+)$/i); - if (bearerMatch) { - return bearerMatch[1].trim(); - } +const getApiKeyFromRequest = (req: Request): string | undefined => { + const headerApiKey = req.header("x-api-key")?.trim(); + if (headerApiKey) return headerApiKey; - return null; + const authorizationHeader = req.header("authorization"); + if (!authorizationHeader) return undefined; + + return parseAuthorizationApiKey(authorizationHeader) ?? undefined; }; export const apiKeyAuthMiddleware = (req: Request, res: Response, next: NextFunction) => { - const rawHeader = req.header("x-api-key") || req.header("authorization"); - - const apiKey = rawHeader - ? rawHeader.startsWith("ApiKey") - ? parseAuthorization(rawHeader)! - : rawHeader - : undefined; + const apiKey = getApiKeyFromRequest(req); if (!apiKey) { res.status(401).json({ error: "API key missing" }); @@ -163,7 +186,7 @@ export const apiKeyAuthMiddleware = (req: Request, res: Response, next: NextFunc return; } - // Inject metadata for downstream handlers if needed + // Inject metadata for downstream handlers if needed. req.apiKey = { id: record.id }; next(); }; diff --git a/src/repositories/streamRepository.ts b/src/repositories/streamRepository.ts index f78c29e..b3bb960 100644 --- a/src/repositories/streamRepository.ts +++ b/src/repositories/streamRepository.ts @@ -1,6 +1,6 @@ -import { eq, and, or, desc, lt, sql } from "drizzle-orm"; +import { and, desc, eq, sql, type SQL } from "drizzle-orm"; import { db } from "../db/index"; -import { streams, Stream, NewStream } from "../db/schema"; +import { Stream, streams } from "../db/schema"; /** * Filters for {@link StreamRepository.findAll}. @@ -24,13 +24,13 @@ export interface FindAllParams { } /** - * Patch payload accepted by {@link StreamRepository.update}. + * Patch payload accepted by {@link StreamRepository.updateById}. * * Only the supplied fields are written; missing fields are left untouched. */ export interface UpdateStreamParams { labels?: string[]; - offChainMemo?: string; + offChainMemo?: string | null; status?: "active" | "paused" | "cancelled" | "completed"; updatedAt?: Date; } @@ -54,7 +54,7 @@ export interface ExportBatch { export class StreamRepository { async findById(id: string, includeDeleted = false): Promise<(Stream & { accruedEstimate: string }) | null> { - const conditions = [eq(streams.id, id)]; + const conditions: SQL[] = [eq(streams.id, id)]; if (!includeDeleted) conditions.push(sql`${streams.deletedAt} IS NULL`); const [result] = await db @@ -73,11 +73,11 @@ export class StreamRepository { }; } - async findAll(params: FindAllParams) { + async findAll(params: FindAllParams = {}) { const limit = Math.min(params.limit ?? 20, 100); const offset = params.offset ?? 0; - const conditions = []; + const conditions: SQL[] = []; if (params.payer) conditions.push(eq(streams.payer, params.payer)); if (params.recipient) conditions.push(eq(streams.recipient, params.recipient)); if (params.status) conditions.push(eq(streams.status, params.status)); @@ -86,25 +86,24 @@ export class StreamRepository { conditions.push(sql`${streams.deletedAt} IS NULL`); } - const query = db + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const data = await db .select() .from(streams) - .where(conditions.length > 0 ? and(...conditions) : undefined) + .where(whereClause) .orderBy(desc(streams.createdAt)) .limit(limit) .offset(offset); - const data = await query; - - // For total count const [countResult] = await db .select({ count: sql`count(*)` }) .from(streams) - .where(conditions.length > 0 ? and(...conditions) : undefined); + .where(whereClause); return { streams: data, - total: Number(countResult.count), + total: Number(countResult?.count ?? 0), limit, offset, }; @@ -116,7 +115,7 @@ export class StreamRepository { updatedAt: new Date(), }; - const conditions = [eq(streams.id, id)]; + const conditions: SQL[] = [eq(streams.id, id)]; if (currentUpdatedAt) { conditions.push(eq(streams.updatedAt, currentUpdatedAt)); } @@ -127,7 +126,7 @@ export class StreamRepository { .where(and(...conditions)) .returning(); - return result[0] || null; + return result[0] ?? null; } private calculateAccruedEstimate(stream: Stream): number { diff --git a/src/routes/webhooks/indexer.ts b/src/routes/webhooks/indexer.ts index be56a8a..d6819cb 100644 --- a/src/routes/webhooks/indexer.ts +++ b/src/routes/webhooks/indexer.ts @@ -1,40 +1,49 @@ -import { Request, Response, Router } from "express"; +import express, { Request, Response, Router } from "express"; +import { apiKeyAuthMiddleware } from "../../middleware/apiKeyAuth"; import { eventIngestionService } from "../../services/eventIngestionService"; -const router = Router(); +export const INDEXER_WEBHOOK_BODY_LIMIT = "100kb"; -router.post("/", (req: Request, unknown, Buffer>, res: Response) => { - if (!Buffer.isBuffer(req.body)) { - return res.status(400).json({ - error: "invalid_body", - message: "Indexer webhook requires the raw request body for signature verification.", - }); - } - - const signatureHeader = req.header("x-indexer-signature") ?? undefined; - const result = eventIngestionService.ingest(req.body, signatureHeader); - - if (!result.accepted) { - const statusByCode = { - missing_secret: 500, - invalid_signature: 401, - invalid_json: 400, - invalid_payload: 400, - } as const; - - return res.status(statusByCode[result.code]).json({ - error: result.code, - message: result.message, +const router = Router(); +const rawJsonBodyParser = express.raw({ type: "application/json", limit: INDEXER_WEBHOOK_BODY_LIMIT }); + +router.post( + "/", + apiKeyAuthMiddleware, + rawJsonBodyParser, + (req: Request, unknown, Buffer>, res: Response) => { + if (!Buffer.isBuffer(req.body)) { + return res.status(400).json({ + error: "invalid_body", + message: "Indexer webhook requires the raw request body for signature verification.", + }); + } + + const signatureHeader = req.header("x-indexer-signature") ?? undefined; + const result = eventIngestionService.ingest(req.body, signatureHeader); + + if (!result.accepted) { + const statusByCode = { + missing_secret: 500, + invalid_signature: 401, + invalid_json: 400, + invalid_payload: 400, + } as const; + + return res.status(statusByCode[result.code]).json({ + error: result.code, + message: result.message, + }); + } + + return res.status(result.duplicate ? 202 : 200).json({ + accepted: true, + duplicate: result.duplicate, + eventId: result.event.eventId, + eventType: result.event.eventType, }); - } - - return res.status(result.duplicate ? 202 : 200).json({ - accepted: true, - duplicate: result.duplicate, - eventId: result.event.eventId, - eventType: result.event.eventType, - }); -}); + }, +); export default router; diff --git a/tsconfig.json b/tsconfig.json index 56764a6..589ab0d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,5 @@ "sourceMap": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/__tests__/**"] } From 8db139b373db93f41dfee682a96041437992fc5d Mon Sep 17 00:00:00 2001 From: "Cyne Jarvis J. Zarceno" <165563006+Kuhai9801@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:34:52 +0800 Subject: [PATCH 2/2] fix: restore backend build dependencies --- package-lock.json | 223 ++++++++++++++++++++++++++++++++++++++++- package.json | 3 + src/config/env.ts | 4 + src/middleware/auth.ts | 4 +- 4 files changed, 231 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 13975e9..4d459b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "streampay-backend", "version": "0.1.0", + "license": "MIT", "dependencies": { "@asteasolutions/zod-to-openapi": "^8.5.0", "cors": "^2.8.5", @@ -14,8 +15,10 @@ "drizzle-orm": "^0.45.2", "express": "^4.21.0", "express-rate-limit": "^8.3.2", + "jsonwebtoken": "^9.0.3", "pg": "^8.20.0", "prom-client": "^15.1.3", + "redis": "^4.7.1", "zod": "^4.3.6" }, "devDependencies": { @@ -23,6 +26,7 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/jest": "^29.5.12", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.9.1", "@types/pg": "^8.20.0", "@types/supertest": "^6.0.2", @@ -2235,6 +2239,71 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", + "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -2468,6 +2537,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -2482,6 +2562,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.9.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", @@ -3292,6 +3379,12 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3481,6 +3574,15 @@ "node": ">=12" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -3957,6 +4059,15 @@ "xtend": "^4.0.0" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -4718,6 +4829,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -5960,6 +6080,49 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6027,6 +6190,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -6041,6 +6240,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6892,6 +7097,23 @@ "node": ">=8.10.0" } }, + "node_modules/redis": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", + "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.1", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7020,7 +7242,6 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" diff --git a/package.json b/package.json index 782f822..e341575 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,10 @@ "drizzle-orm": "^0.45.2", "express": "^4.21.0", "express-rate-limit": "^8.3.2", + "jsonwebtoken": "^9.0.3", "pg": "^8.20.0", "prom-client": "^15.1.3", + "redis": "^4.7.1", "zod": "^4.3.6" }, "devDependencies": { @@ -52,6 +54,7 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/jest": "^29.5.12", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.9.1", "@types/pg": "^8.20.0", "@types/supertest": "^6.0.2", diff --git a/src/config/env.ts b/src/config/env.ts index e9740e5..cab5cf3 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -74,7 +74,11 @@ export const env = process.env.NODE_ENV === "test" PORT: 3001, DATABASE_URL: "postgres://user:password@localhost:5432/streampay", JWT_SECRET: "test_secret_key_at_least_32_characters_long", + JWT_PUBLIC_KEY: undefined, + JWT_ISSUER: undefined, + JWT_AUDIENCE: undefined, RPC_URL: "https://api.testnet.solana.com", + AUDIT_LOG_RETENTION_DAYS: 365, NODE_ENV: "test" as const, DB_POOL_MAX: 5, DB_POOL_IDLE_TIMEOUT: 10000, diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 2d511a0..ed7043f 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,5 +1,5 @@ import { Request, Response, NextFunction } from "express"; -import jwt, { JwtPayload, VerifyOptions } from "jsonwebtoken"; +import jwt, { JwtPayload, VerifyErrors, VerifyOptions } from "jsonwebtoken"; import { env } from "../config/env"; /** @@ -53,7 +53,7 @@ export function authenticateJWT( ...(env.JWT_AUDIENCE && { audience: env.JWT_AUDIENCE }), }; - jwt.verify(token, secret, options, (err, decoded) => { + jwt.verify(token, secret, options, (err: VerifyErrors | null, decoded: JwtPayload | string | undefined) => { if (err) { const message = err.name === "TokenExpiredError"