UNPKG

codalware-auth

Version:

Complete authentication system with enterprise security, attack protection, team workspaces, waitlist, billing, UI components, 2FA, and account recovery - production-ready in 5 minutes. Enhanced CLI with verification, rollback, and App Router scaffolding.

171 lines (148 loc) 5.88 kB
import crypto from 'crypto'; import { IncomingMessage } from 'http'; import fs from 'fs'; import path from 'path'; export type IdempotencyStore = { has: (id: string) => Promise<boolean>; set: (id: string) => Promise<void>; }; // Default file-backed idempotency store (simple, safe for demo) export function fileIdempotencyStore(fileName = '.webhook_processed.json'): IdempotencyStore { const filePath = path.resolve(process.cwd(), fileName); let cache: Set<string> | null = null; const load = async () => { if (cache) return cache; try { const txt = await fs.promises.readFile(filePath, 'utf8'); const arr = JSON.parse(txt || '[]'); cache = new Set(Array.isArray(arr) ? arr : []); } catch { cache = new Set(); } return cache; }; const persist = async () => { if (!cache) return; await fs.promises.writeFile(filePath, JSON.stringify(Array.from(cache)), { encoding: 'utf8' }); }; return { has: async (id: string) => { const c = await load(); return c.has(id); }, set: async (id: string) => { const c = await load(); c.add(id); await persist(); } }; } // Read raw body from Next.js API request (IncomingMessage) export async function getRawBody(req: IncomingMessage): Promise<Buffer> { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; req.on('data', (chunk: Buffer) => chunks.push(chunk)); req.on('end', () => resolve(Buffer.concat(chunks))); req.on('error', reject); }); } // Generic HMAC signature verifier. Supports hex or base64. export function verifyHmacSignature(payload: Buffer | string, secret: string, signatureHeaderValue?: string, algorithm = 'sha256') { if (!signatureHeaderValue) return false; const hmac = crypto.createHmac(algorithm, secret); hmac.update(payload); const digestHex = hmac.digest('hex'); // Common header formats: "sha256=..." or raw hex/base64 const header = signatureHeaderValue.trim(); let received = header; if (header.includes('=')) { const parts = header.split('='); received = parts[1]; } try { const a = Buffer.from(digestHex, 'hex'); const b = Buffer.from(received, received.length === a.length ? 'hex' : 'base64'); if (a.length !== b.length) return false; return crypto.timingSafeEqual(a, b); } catch { return false; } } // Lightweight helper to normalize event id extraction from headers type WebhookHeaders = Record<string, string | string[] | undefined>; export function extractEventId(headers: WebhookHeaders): string | null { const candidates = ['x-event-id', 'x-request-id', 'id', 'x-github-delivery', 'stripe-event-id', 'x-webhook-id']; for (const k of candidates) { const v = headers[k] || headers[k.toLowerCase()]; if (v) return Array.isArray(v) ? v[0] : String(v); } return null; } // Wrap a handler with common webhook concerns: raw body, optional signature verification, idempotency export type WebhookHandler = (opts: { raw: Buffer; body: unknown; headers: WebhookHeaders; eventId?: string }) => Promise<void> | void; export type RegisterOptions = { secret?: string; // secret for HMAC verification signatureHeader?: string; // header that contains signature signatureAlgorithm?: string; // default sha256 idempotencyStore?: IdempotencyStore; // optional store requireSignature?: boolean; // if true, reject when signature missing/invalid }; export function registerWebhook(handler: WebhookHandler, opts: RegisterOptions = {}) { const { secret, signatureHeader = 'x-signature', signatureAlgorithm = 'sha256', idempotencyStore, requireSignature = false, } = opts; return async function process(req: IncomingMessage): Promise<{ status: number; message?: string }> { try { const raw = await getRawBody(req); const headers = req.headers as WebhookHeaders; // If signature is provided, verify const rawSignature = headers[signatureHeader] ?? headers[signatureHeader.toLowerCase()]; const sigHeaderValue = Array.isArray(rawSignature) ? rawSignature[0] : rawSignature; if (secret) { const ok = verifyHmacSignature(raw, secret, sigHeaderValue, signatureAlgorithm); if (!ok) { return { status: 401, message: 'invalid signature' }; } } else if (requireSignature) { return { status: 401, message: 'signature required' }; } // Parse JSON if content-type let body: unknown = null; try { const text = raw.toString('utf8'); if (text && text.trim().length) body = JSON.parse(text); } catch { // leave body null for non-json payloads body = null; } let derivedEventId: string | null = null; if (body && typeof body === 'object') { const record = body as Record<string, unknown>; const candidate = record.id ?? record.event_id ?? record.eventId; derivedEventId = typeof candidate === 'string' ? candidate : null; } const eventId = extractEventId(headers) ?? derivedEventId; // Idempotency check if (idempotencyStore && eventId) { const seen = await idempotencyStore.has(eventId); if (seen) { return { status: 200, message: 'already processed' }; } } // Call handler await handler({ raw, body, headers, eventId: eventId ?? undefined }); // Mark processed if (idempotencyStore && eventId) { await idempotencyStore.set(eventId); } return { status: 200 }; } catch (error) { console.error('Webhook processing error:', error); return { status: 500, message: 'internal error' }; } }; }