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
text/typescript
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' };
}
};
}