espace-paie-odentas/app/api/odentas-sign/webhooks/completion/route.ts
odentas 40ab28fdc7 feat: Activation du workflow complet PAdES + TSA
- Retrait du bypass mode test dans le webhook completion
- Appel des Lambdas pades-sign et tsaStamp pour toutes les demandes
- Workflow complet: signature → PAdES seal → TSA timestamp → archive
- Graceful degradation si Lambdas non disponibles (local)
- Evidence bundle mis à jour avec hash PDF et TSA metadata
- Script de test automatique test-complete-signature-flow.sh
- Documentation complète TEST_PADES_TSA.md
2025-10-27 19:35:04 +01:00

304 lines
10 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { supabaseAdmin, logSignEvent, getSignEvents } from '@/lib/odentas-sign/supabase';
import { uploadEvidenceBundle } from '@/lib/odentas-sign/s3';
import type { EvidenceBundle } from '@/lib/odentas-sign/types';
/**
* POST /api/odentas-sign/webhooks/completion
*
* Webhook appelé quand tous les signataires ont signé
* Lance le workflow de scellage : injection signatures → PAdES → TSA → Archive
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { requestId } = body;
if (!requestId) {
return NextResponse.json(
{ error: 'requestId manquant' },
{ status: 400 }
);
}
console.log(`[WEBHOOK COMPLETION] Début traitement pour request ${requestId}`);
// 1. Récupérer toutes les données de la demande
const { data: signRequest, error: requestError } = await supabaseAdmin
.from('sign_requests')
.select(`
*,
signers(*),
sign_positions(*)
`)
.eq('id', requestId)
.single();
if (requestError || !signRequest) {
console.error('[WEBHOOK] Erreur récupération demande:', requestError);
return NextResponse.json(
{ error: 'Demande introuvable' },
{ status: 404 }
);
}
// Vérifier que tous ont bien signé
const allSigned = signRequest.signers.every((s: any) => s.signed_at !== null);
if (!allSigned) {
return NextResponse.json(
{ error: 'Tous les signataires n\'ont pas encore signé' },
{ status: 400 }
);
}
// 2. Logger l'événement de début de scellage
await logSignEvent({
requestId: signRequest.id,
event: 'sealing_started',
metadata: {
signers_count: signRequest.signers.length,
},
});
// 3. Récupérer tous les événements pour le bundle de preuves
const events = await getSignEvents(requestId);
// 4. Créer le bundle de preuves (evidence)
const evidenceBundle: EvidenceBundle = {
request_id: signRequest.id,
request_ref: signRequest.ref,
title: signRequest.title,
created_at: signRequest.created_at,
completed_at: new Date().toISOString(),
eidas_level: 'SES', // Signature Électronique Simple pour le moment
signers: signRequest.signers.map((s: any) => ({
id: s.id,
role: s.role,
name: s.name,
email: s.email,
signed_at: s.signed_at,
ip_address: s.ip_signed || 'N/A',
user_agent: s.user_agent || 'N/A',
consent_text: s.consent_text,
consent_at: s.consent_at,
signature_method: 'drawn', // TODO: détecter drawn vs uploaded
authentication: {
method: 'OTP',
otp_sent_at: events.find((e: any) => e.event === 'otp_sent' && e.signer_id === s.id)?.ts || 'N/A',
otp_verified_at: events.find((e: any) => e.event === 'otp_verified' && e.signer_id === s.id)?.ts || 'N/A',
email_verified: true,
},
})),
events: events.map((e: any) => ({
timestamp: e.ts,
event: e.event,
actor: e.signer_id || null,
ip: e.ip || null,
metadata: e.metadata,
})),
seal: {
algorithm: 'RSASSA_PSS_SHA_256',
kms_key_id: process.env.KMS_KEY_ID || 'N/A',
sealed_at: '',
pdf_sha256: '',
},
tsa: {
url: process.env.TSA_URL || 'https://timestamp.sectigo.com',
tsr_sha256: '',
policy_oid: null,
serial: null,
},
retention: {
archive_key: '',
retain_until: '',
compliance_mode: 'COMPLIANCE',
},
};
// 5. Upload du bundle de preuves initial (sera mis à jour après scellage)
const evidenceKey = await uploadEvidenceBundle({
requestRef: signRequest.ref,
evidence: evidenceBundle,
});
console.log(`[WEBHOOK] ✅ Evidence bundle uploadé: ${evidenceKey}`);
// 6. Workflow de scellage PAdES + TSA
console.log(`[WEBHOOK] 🔒 Début du workflow de scellage...`);
try {
// Étape 1: Appeler lambda-odentas-pades-sign pour sceller le PDF
console.log(`[WEBHOOK] 📝 Appel de lambda-odentas-pades-sign...`);
const padesPayload = {
source_s3_key: signRequest.source_s3_key,
signatures: signRequest.signers.map((s: any) => {
// Trouver la clé S3 de la signature de ce signataire
const signatureKey = `signatures/${signRequest.ref}/${s.id}.png`;
return {
signer_id: s.id,
s3_key: signatureKey,
positions: signRequest.sign_positions
.filter((p: any) => p.role === s.role)
.map((p: any) => ({
page: p.page,
x: p.x,
y: p.y,
width: p.w,
height: p.h,
})),
};
}),
output_key: `signed/${signRequest.ref}.pdf`,
};
console.log(`[WEBHOOK] Payload PAdES:`, JSON.stringify(padesPayload, null, 2));
// En local, on simule la Lambda (en production, faire un appel Lambda réel)
const padesResponse = await fetch(process.env.LAMBDA_PADES_URL || 'http://localhost:9000/2015-03-31/functions/function/invocations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(padesPayload),
}).catch((err) => {
console.error('[WEBHOOK] ⚠️ Lambda PAdES non accessible (normal en local):', err.message);
return null;
});
let sealedPdfKey = `signed/${signRequest.ref}.pdf`;
let pdfHash = '';
if (padesResponse && padesResponse.ok) {
const padesResult = await padesResponse.json();
console.log(`[WEBHOOK] ✅ PAdES seal appliqué`);
sealedPdfKey = padesResult.signed_pdf_key;
pdfHash = padesResult.pdf_sha256;
} else {
console.log(`[WEBHOOK] ⚠️ PAdES seal skipped (Lambda non disponible en local)`);
}
// Étape 2: Appeler lambda-tsaStamp pour horodater
console.log(`[WEBHOOK] ⏱️ Appel de lambda-tsaStamp...`);
const tsaPayload = {
pdf_s3_key: sealedPdfKey,
hash_to_timestamp: pdfHash,
};
const tsaResponse = await fetch(process.env.LAMBDA_TSA_URL || 'http://localhost:9001/2015-03-31/functions/function/invocations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tsaPayload),
}).catch((err) => {
console.error('[WEBHOOK] ⚠️ Lambda TSA non accessible (normal en local):', err.message);
return null;
});
let tsaSerial = null;
let tsaPolicyOid = null;
let tsrKey = '';
if (tsaResponse && tsaResponse.ok) {
const tsaResult = await tsaResponse.json();
console.log(`[WEBHOOK] ✅ TSA timestamp obtenu`);
tsaSerial = tsaResult.serial_number;
tsaPolicyOid = tsaResult.policy_oid;
tsrKey = tsaResult.tsr_s3_key;
} else {
console.log(`[WEBHOOK] ⚠️ TSA timestamp skipped (Lambda non disponible en local)`);
}
// Étape 3: Mettre à jour l'evidence bundle avec les infos de scellage
evidenceBundle.seal.sealed_at = new Date().toISOString();
evidenceBundle.seal.pdf_sha256 = pdfHash;
evidenceBundle.tsa.serial = tsaSerial;
evidenceBundle.tsa.policy_oid = tsaPolicyOid;
evidenceBundle.tsa.tsr_sha256 = tsrKey;
evidenceBundle.retention.archive_key = sealedPdfKey;
const retainUntilDate = new Date();
retainUntilDate.setFullYear(retainUntilDate.getFullYear() + 10);
evidenceBundle.retention.retain_until = retainUntilDate.toISOString();
// Uploader l'evidence bundle mis à jour
await uploadEvidenceBundle({
requestRef: signRequest.ref,
evidence: evidenceBundle,
});
console.log(`[WEBHOOK] ✅ Evidence bundle mis à jour`);
// Étape 4: Créer l'enregistrement dans sign_assets
const { error: assetsError } = await supabaseAdmin
.from('sign_assets')
.insert({
request_id: requestId,
evidence_json_s3_key: evidenceKey,
signed_pdf_s3_key: sealedPdfKey,
tsa_tsr_s3_key: tsrKey || null,
retain_until: retainUntilDate.toISOString(),
});
if (assetsError) {
console.error('[WEBHOOK] Erreur création sign_assets:', assetsError);
}
// Étape 5: Mettre à jour le statut de la demande
const { error: updateError } = await supabaseAdmin
.from('sign_requests')
.update({ status: 'completed' })
.eq('id', requestId);
if (updateError) {
console.error('[WEBHOOK] Erreur mise à jour statut:', updateError);
}
console.log(`[WEBHOOK] ✅ Workflow de scellage terminé`);
} catch (sealError) {
console.error('[WEBHOOK] ❌ Erreur workflow de scellage:', sealError);
// En cas d'erreur, on complète quand même la demande
const { error: updateError } = await supabaseAdmin
.from('sign_requests')
.update({ status: 'completed' })
.eq('id', requestId);
if (updateError) {
console.error('[WEBHOOK] Erreur mise à jour statut:', updateError);
}
}
// 7. Logger la completion
await logSignEvent({
requestId: signRequest.id,
event: 'request_completed',
metadata: {
evidence_key: evidenceKey,
status: 'completed',
},
});
// 8. TODO: Envoyer les emails de notification aux signataires
console.log(`[WEBHOOK] TODO: Envoyer emails de completion`);
console.log(`[WEBHOOK COMPLETION] ✅ Traitement terminé pour ${signRequest.ref}`);
return NextResponse.json({
success: true,
message: 'Workflow de scellage terminé',
request: {
id: signRequest.id,
ref: signRequest.ref,
status: 'completed',
},
evidence_key: evidenceKey,
});
} catch (error) {
console.error('[WEBHOOK COMPLETION] Erreur:', error);
return NextResponse.json(
{ error: 'Erreur serveur', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}