diff --git a/.env.example b/.env.example index 24b31e5..72fd237 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,9 @@ SQLITE_PATH=./data/uber_wrapper.db # Shared API key for wrapper clients (optional but recommended) WRAPPER_API_KEY=change-me + +# Webhook security +UBER_WEBHOOK_SIGNATURE_REQUIRED=true +# Optional: if you configure Basic Auth in Uber dashboard for webhook delivery +WEBHOOK_BASIC_AUTH_USERNAME= +WEBHOOK_BASIC_AUTH_PASSWORD= diff --git a/docs/developer-portal/07-webhooks-audit.md b/docs/developer-portal/07-webhooks-audit.md new file mode 100644 index 0000000..4c67442 --- /dev/null +++ b/docs/developer-portal/07-webhooks-audit.md @@ -0,0 +1,30 @@ +# 07 Webhooks Audit + +Source checked: Uber Eats "Webhook" section shared by you. + +## Implemented Now + +- Webhook signature verification using `X-Uber-Signature`: + - HMAC SHA256 over raw request body + - key = Uber client secret +- Optional webhook Basic Auth validation if configured in `.env`. +- Retry-safe de-duplication via deterministic webhook dedupe key. +- Response behavior aligned to doc: + - return `200` with empty body on valid webhook receipt. +- Persisted webhook metadata includes: + - `event_type` + - `resource_id` + - `resource_href` + - signature and dedupe key + +## Existing Before + +- Webhook endpoint already present. +- Event payload and headers persistence. + +## Pending + +- Per-event downstream job workers (accept/deny SLA orchestration). +- Alerting if order accept/deny not sent before timeout window. +- Full typed schema validation per webhook `event_type`. + diff --git a/docs/developer-portal/07-webhooks.md b/docs/developer-portal/07-webhooks.md index 335736b..a5a65f8 100644 --- a/docs/developer-portal/07-webhooks.md +++ b/docs/developer-portal/07-webhooks.md @@ -6,6 +6,11 @@ Webhook design goals: - Idempotent processing - Retry-safe handlers - Event logging and replay support +- Signature verification (`X-Uber-Signature` HMAC SHA256 with client secret) Current ingestion endpoint: `POST /api/v1/webhooks/uber` +Acknowledgement behavior: + +- Valid webhook events are acknowledged with `200` and empty body +- Duplicate retries are de-duplicated and still acknowledged with `200` diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index d75d8ac..a7ec25c 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -323,8 +323,8 @@ "Webhooks" ], "responses": { - "202": { - "description": "Webhook accepted" + "200": { + "description": "Webhook accepted (empty body)" } } } diff --git a/postman/Uber_Wrapper.postman_collection.json b/postman/Uber_Wrapper.postman_collection.json index 03d83a7..8416ef5 100644 --- a/postman/Uber_Wrapper.postman_collection.json +++ b/postman/Uber_Wrapper.postman_collection.json @@ -199,6 +199,38 @@ } } }, + { + "name": "Webhook Ingest (Simulation)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Uber-Signature", + "value": "replace-with-valid-hmac" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"event_type\": \"orders.notification\",\n \"resource_id\": \"ORDER_ID\",\n \"resource_href\": \"/v1/eats/orders/ORDER_ID\",\n \"order\": {\n \"id\": \"ORDER_ID\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/v1/webhooks/uber", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "webhooks", + "uber" + ] + } + } + }, { "name": "Generic Uber Request", "request": { diff --git a/src/app.js b/src/app.js index ade1c4b..4444a8c 100644 --- a/src/app.js +++ b/src/app.js @@ -18,7 +18,14 @@ const app = express(); app.use(helmet()); app.use(cors()); -app.use(express.json({ limit: "5mb" })); +app.use( + express.json({ + limit: "5mb", + verify: (req, res, buf) => { + req.rawBody = Buffer.from(buf); + } + }) +); app.use(morgan("combined")); app.use(requestContext); diff --git a/src/config/env.js b/src/config/env.js index bc95778..56e2f97 100644 --- a/src/config/env.js +++ b/src/config/env.js @@ -9,6 +9,11 @@ const envSchema = z.object({ UBER_REDIRECT_URI: z.string().url(), UBER_OAUTH_BASE_URL: z.string().url().default("https://auth.uber.com"), UBER_API_BASE_URL: z.string().url().default("https://api.uber.com"), + UBER_WEBHOOK_SIGNATURE_REQUIRED: z + .preprocess((value) => String(value ?? "true").toLowerCase(), z.enum(["true", "false"])) + .transform((value) => value === "true"), + WEBHOOK_BASIC_AUTH_USERNAME: z.string().optional(), + WEBHOOK_BASIC_AUTH_PASSWORD: z.string().optional(), SQLITE_PATH: z.string().default("./data/uber_wrapper.db"), WRAPPER_API_KEY: z.string().optional() }); diff --git a/src/db/repositories.js b/src/db/repositories.js index 51517c7..9256781 100644 --- a/src/db/repositories.js +++ b/src/db/repositories.js @@ -96,12 +96,33 @@ const uberConnectionRepository = { }; const webhookRepository = { - insert({ provider, merchantId, eventType, payloadJson, headersJson }) { + findByDedupeKey(dedupeKey) { + if (!dedupeKey) { + return null; + } + return db.prepare("SELECT * FROM webhook_events WHERE dedupe_key = ? LIMIT 1").get(dedupeKey); + }, + + insert({ + provider, + merchantId, + eventType, + resourceId, + resourceHref, + uberSignature, + dedupeKey, + payloadJson, + headersJson + }) { const row = { id: uuidv4(), provider, merchant_id: merchantId || null, event_type: eventType || null, + resource_id: resourceId || null, + resource_href: resourceHref || null, + uber_signature: uberSignature || null, + dedupe_key: dedupeKey || null, payload_json: JSON.stringify(payloadJson || {}), headers_json: JSON.stringify(headersJson || {}), received_at: nowIso(), @@ -109,11 +130,13 @@ const webhookRepository = { }; const stmt = db.prepare(` INSERT INTO webhook_events ( - id, provider, merchant_id, event_type, payload_json, headers_json, + id, provider, merchant_id, event_type, resource_id, resource_href, uber_signature, dedupe_key, + payload_json, headers_json, received_at, processing_status ) VALUES ( - @id, @provider, @merchant_id, @event_type, @payload_json, @headers_json, + @id, @provider, @merchant_id, @event_type, @resource_id, @resource_href, @uber_signature, @dedupe_key, + @payload_json, @headers_json, @received_at, @processing_status ) `); diff --git a/src/db/sqlite.js b/src/db/sqlite.js index 017a11d..cec85cf 100644 --- a/src/db/sqlite.js +++ b/src/db/sqlite.js @@ -11,6 +11,11 @@ if (!fs.existsSync(dbDir)) { const db = new Database(env.SQLITE_PATH); db.pragma("journal_mode = WAL"); +function tableHasColumn(tableName, columnName) { + const columns = db.prepare(`PRAGMA table_info(${tableName})`).all(); + return columns.some((column) => column.name === columnName); +} + function initSchema() { db.exec(` CREATE TABLE IF NOT EXISTS merchants ( @@ -45,6 +50,10 @@ function initSchema() { provider TEXT NOT NULL, merchant_id TEXT, event_type TEXT, + resource_id TEXT, + resource_href TEXT, + uber_signature TEXT, + dedupe_key TEXT, payload_json TEXT NOT NULL, headers_json TEXT, received_at TEXT NOT NULL, @@ -88,6 +97,24 @@ function initSchema() { requested_at TEXT NOT NULL ); `); + + if (!tableHasColumn("webhook_events", "resource_id")) { + db.exec("ALTER TABLE webhook_events ADD COLUMN resource_id TEXT"); + } + if (!tableHasColumn("webhook_events", "resource_href")) { + db.exec("ALTER TABLE webhook_events ADD COLUMN resource_href TEXT"); + } + if (!tableHasColumn("webhook_events", "uber_signature")) { + db.exec("ALTER TABLE webhook_events ADD COLUMN uber_signature TEXT"); + } + if (!tableHasColumn("webhook_events", "dedupe_key")) { + db.exec("ALTER TABLE webhook_events ADD COLUMN dedupe_key TEXT"); + } + + db.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_webhook_events_dedupe_key + ON webhook_events(dedupe_key); + `); } module.exports = { diff --git a/src/modules/webhooks/webhooks.controller.js b/src/modules/webhooks/webhooks.controller.js index f508549..6b27411 100644 --- a/src/modules/webhooks/webhooks.controller.js +++ b/src/modules/webhooks/webhooks.controller.js @@ -1,25 +1,112 @@ +const crypto = require("crypto"); +const env = require("../../config/env"); const { webhookRepository } = require("../../db/adapter"); +function getSignatureFromHeaders(headers) { + const signature = headers["x-uber-signature"]; + if (!signature) { + return null; + } + if (Array.isArray(signature)) { + return String(signature[0] || "").toLowerCase(); + } + return String(signature).toLowerCase(); +} + +function getRawBody(req) { + if (req.rawBody && Buffer.isBuffer(req.rawBody)) { + return req.rawBody; + } + return Buffer.from(JSON.stringify(req.body || {}), "utf8"); +} + +function verifyBasicAuthIfConfigured(req) { + const expectedUser = env.WEBHOOK_BASIC_AUTH_USERNAME || ""; + const expectedPass = env.WEBHOOK_BASIC_AUTH_PASSWORD || ""; + if (!expectedUser && !expectedPass) { + return true; + } + + const authHeader = req.headers.authorization || ""; + if (!authHeader.startsWith("Basic ")) { + return false; + } + const encoded = authHeader.slice(6).trim(); + const decoded = Buffer.from(encoded, "base64").toString("utf8"); + const [user, pass] = decoded.split(":"); + return user === expectedUser && pass === expectedPass; +} + +function verifyUberSignature(req) { + const signature = getSignatureFromHeaders(req.headers); + if (!signature) { + return { ok: !env.UBER_WEBHOOK_SIGNATURE_REQUIRED, signature: null }; + } + + const rawBody = getRawBody(req); + const expected = crypto + .createHmac("sha256", env.UBER_CLIENT_SECRET) + .update(rawBody) + .digest("hex") + .toLowerCase(); + + const incomingBuffer = Buffer.from(signature, "utf8"); + const expectedBuffer = Buffer.from(expected, "utf8"); + const sameLength = incomingBuffer.length === expectedBuffer.length; + const valid = + sameLength && crypto.timingSafeEqual(incomingBuffer, expectedBuffer); + + return { ok: valid, signature }; +} + +function buildDedupeKey(signature, req) { + const rawBody = getRawBody(req); + const basis = `${signature || "no-signature"}:${rawBody.toString("utf8")}`; + return crypto.createHash("sha256").update(basis).digest("hex"); +} + async function handleUberWebhook(req, res) { + if (!verifyBasicAuthIfConfigured(req)) { + return res.status(401).json({ + success: false, + message: "Invalid webhook basic auth credentials" + }); + } + + const verification = verifyUberSignature(req); + if (!verification.ok) { + return res.status(401).json({ + success: false, + message: "Invalid or missing X-Uber-Signature" + }); + } + const merchantId = req.query.merchantId || req.body?.merchant_id || null; const eventType = req.body?.event_type || req.body?.type || "unknown"; + const resourceId = req.body?.resource_id || req.body?.order_id || req.body?.order?.id || null; + const resourceHref = req.body?.resource_href || null; + const dedupeKey = buildDedupeKey(verification.signature, req); - const row = webhookRepository.insert({ + const existing = webhookRepository.findByDedupeKey(dedupeKey); + if (existing) { + return res.status(200).end(); + } + + webhookRepository.insert({ provider: "uber", merchantId, eventType, + resourceId, + resourceHref, + uberSignature: verification.signature, + dedupeKey, payloadJson: req.body, headersJson: req.headers }); - return res.status(202).json({ - success: true, - message: "Webhook received", - data: { webhookEventId: row.id } - }); + return res.status(200).end(); } module.exports = { handleUberWebhook }; - diff --git a/src/routes/webhooks.routes.js b/src/routes/webhooks.routes.js index 757692d..4386917 100644 --- a/src/routes/webhooks.routes.js +++ b/src/routes/webhooks.routes.js @@ -12,10 +12,9 @@ const router = express.Router(); * tags: * - Webhooks * responses: - * 202: - * description: Webhook accepted + * 200: + * description: Webhook accepted (empty body) */ router.post("/webhooks/uber", asyncHandler(handleUberWebhook)); module.exports = router; -