FusionAuth Webhooks — Recepción y Verificación JWT
Referencia de código para recibir y verificar webhooks de FusionAuth usando firma JWT (HMAC). Incluye handlers listos para Express y FastAPI con verificación de integridad del cuerpo de la petición.
Descarga abierta · sin registro · para Node.js, Express, Python
// Generated with: fusionauth-webhooks skill // https://github.com/hookdeck/webhook-skills // Cliente: NutriFlow SaaS — Webhooks de autenticación FusionAuth // Skill: fusionauth-webhooks-verificacion (CULTIVA IA / Web)
'use strict';
const express = require('express'); const crypto = require('crypto'); const jose = require('jose');
// ─── Configuración ───────────────────────────────────────────────────────────
const PORT = process.env.PORT || 3001; const SECRET = process.env.FUSIONAUTH_WEBHOOK_SECRET; // HMAC key de Key Master
const app = express();
// ─── Utilidad: logger JSON estructurado (compatible Datadog) ─────────────────
function log(level, message, extra = {}) { const entry = { timestamp: new Date().toISOString(), level, service: 'nutriflow-webhooks', message, ...extra, }; // eslint-disable-next-line no-console console.log(JSON.stringify(entry)); }
// ─── Verificación JWT/HMAC de FusionAuth ─────────────────────────────────────
/**
- Verifica la autenticidad del webhook de FusionAuth.
- FusionAuth firma cada petición con un JWT (header X-FusionAuth-Signature-JWT)
- que contiene el claim
request_body_sha256con el hash SHA-256 del body - codificado en base64. Si el JWT está bien firmado Y el hash coincide,
- el evento es auténtico.
- @param {Buffer} rawBody - Body sin parsear (express.raw)
- @param {string} signatureJwt - Valor del header X-FusionAuth-Signature-JWT
- @param {string} hmacSecret - Clave HMAC del Key Master de FusionAuth
- @returns {Promise} */ async function verifyFusionAuthWebhook(rawBody, signatureJwt, hmacSecret) { if (!signatureJwt || !hmacSecret) return false;
try { // 1. Verificar firma del JWT con la clave HMAC const key = new TextEncoder().encode(hmacSecret); const { payload } = await jose.jwtVerify(signatureJwt, key, { algorithms: ['HS256', 'HS384', 'HS512'], });
// 2. Calcular SHA-256 del body raw y comparar con el claim
const bodyHash = crypto
.createHash('sha256')
.update(rawBody)
.digest('base64');
return payload.request_body_sha256 === bodyHash;
} catch (err) { log('error', 'JWT verification failed', { error: err.message }); return false; } }
// ─── Handlers de eventos ─────────────────────────────────────────────────────
/**
- user.create — Nuevo usuario registrado en FusionAuth.
- Acción: crear perfil en BD interna + disparar email de bienvenida. */ async function handleUserCreate(event) { const { id, email, fullName } = event.user ?? {}; log('info', 'user.create — creando perfil en NutriFlow', { userId: id, email });
// TODO (producción): await db.users.create({ fusionAuthId: id, email, fullName }); // TODO (producción): await mailer.sendWelcome({ to: email, name: fullName });
log('info', 'user.create — perfil creado y email de bienvenida encolado', { userId: id }); }
/**
- user.login.success — Login correcto.
- Acción: actualizar lastSeenAt y contador de sesiones. */ async function handleLoginSuccess(event) { const userId = event.user?.id; log('info', 'user.login.success — actualizando último acceso', { userId });
// TODO (producción): await db.users.update(userId, { // lastSeenAt: new Date(event.createInstant), // $increment: { sessionCount: 1 }, // }); }
/**
- user.registration.create — Usuario asignado a una aplicación (clínica).
- Acción: asociar al usuario con el tenant correcto en NutriFlow. */ async function handleRegistrationCreate(event) { const { id: userId } = event.user ?? {}; const { applicationId } = event.registration ?? {}; log('info', 'user.registration.create — asignando clínica', { userId, applicationId });
// TODO (producción): await db.clinicMembers.upsert({ userId, clinicId: applicationId }); }
/**
- user.email.verified — Email verificado.
- Acción: desbloquear funcionalidades premium. */ async function handleEmailVerified(event) { const userId = event.user?.id; log('info', 'user.email.verified — desbloqueando premium', { userId });
// TODO (producción): await db.users.update(userId, { emailVerified: true, tier: 'premium' }); }
/**
- user.deactivate — Cuenta desactivada en FusionAuth.
- Acción: revocar acceso sin borrar datos (soft-disable). */ async function handleUserDeactivate(event) { const userId = event.user?.id; log('warn', 'user.deactivate — revocando acceso', { userId });
// TODO (producción): await db.users.update(userId, { active: false, revokedAt: new Date() }); // TODO (producción): await sessions.revokeAll(userId); }
// ─── Dispatcher ──────────────────────────────────────────────────────────────
const EVENT_HANDLERS = { 'user.create': handleUserCreate, 'user.login.success': handleLoginSuccess, 'user.registration.create': handleRegistrationCreate, 'user.email.verified': handleEmailVerified, 'user.deactivate': handleUserDeactivate, };
async function dispatchEvent(eventType, eventPayload) { const handler = EVENT_HANDLERS[eventType]; if (!handler) { log('warn', 'Evento no manejado — ignorando', { eventType }); return; } await handler(eventPayload); }
// ─── Endpoint principal ──────────────────────────────────────────────────────
/**
POST /webhooks/fusionauth
IMPORTANTE: express.raw() es obligatorio. Si se usa express.json() el body
se re-serializa y el hash SHA-256 ya no coincide con el original. */ app.post( '/webhooks/fusionauth', express.raw({ type: 'application/json' }), async (req, res) => { const startTime = Date.now(); const signatureJwt = req.headers['x-fusionauth-signature-jwt'];
// 1. Verificar firma HMAC-JWT const isValid = await verifyFusionAuthWebhook(req.body, signatureJwt, SECRET); if (!isValid) { log('error', 'Firma inválida — rechazando webhook', { ip: req.ip, hasHeader: !!signatureJwt, }); return res.status(401).json({ error: 'Invalid signature' }); }
// 2. Parsear payload (sólo después de verificar) let event; try { event = JSON.parse(req.body.toString()); } catch (parseErr) { log('error', 'Body no es JSON válido', { error: parseErr.message }); return res.status(400).json({ error: 'Invalid JSON body' }); }
const eventType = event?.event?.type ?? 'unknown'; log('info', 'Webhook recibido', { eventType, eventId: event?.event?.id });
// 3. Despachar handler (sin bloquear la respuesta 2xx) // FusionAuth necesita un 200 rápido; procesamos en background. res.json({ received: true });
try { await dispatchEvent(eventType, event.event); log('info', 'Evento procesado', { eventType, durationMs: Date.now() - startTime, }); } catch (handlerErr) { log('error', 'Error en handler de evento', { eventType, error: handlerErr.message, }); // No relanzamos — la respuesta 200 ya fue enviada y no podemos cambiarla. // En producción: enviar a dead-letter queue o alertar en Datadog. } } );
// ─── Health check ─────────────────────────────────────────────────────────────
app.get('/health', (_req, res) => res.json({ status: 'ok', service: 'nutriflow-webhooks' }));
// ─── Arranque ─────────────────────────────────────────────────────────────────
app.listen(PORT, () => {
log('info', NutriFlow Webhooks escuchando en :${PORT}, {
endpoint: http://localhost:${PORT}/webhooks/fusionauth,
});
});
module.exports = app; // exportado para tests
// qué_hace
Proporciona código listo para usar que recibe eventos de autenticación de FusionAuth (user.create, user.login, etc.) y verifica su autenticidad mediante JWT firmado con HMAC.
// cómo_lo_hace
Extrae el JWT del header X-FusionAuth-Signature-JWT, lo verifica con la clave HMAC secreta, compara el hash SHA-256 del cuerpo y despacha el evento al handler correspondiente.
// ejemplo_de_uso
Necesaria cuando FusionAuth envía eventos de autenticación y tu backend debe procesarlos de forma segura. Ej.: recibe el webhook de user.login, verifica el JWT y registra el acceso en tu base de datos.
// plataformas
// opiniones_de_la_comunidad
Opiniones
Cargando opiniones…
// pase_cultiva_ia
Llévate todo el arsenal con el Pase
Todas las skills, prompts y automatizaciones del catálogo en un único archivo, listas para usar: un pago, acceso de por vida y las novedades que añadamos. Sin suscripción.
Pago único · IVA incluido · pago seguro con Stripe.
Acceso inmediato · si no es lo que esperabas, te devolvemos los 10 €.