// verify-face/index.ts // Follow this setup guide to integrate the Deno language server with your editor: // https://deno.land/manual/getting_started/setup_your_environment // This enables autocomplete, go to definition, etc. // Setup type definitions for built-in Supabase Runtime APIs import "jsr:@supabase/functions-js/edge-runtime.d.ts"; import { serve } from "https://deno.land/std@0.177.0/http/server.ts"; import { RekognitionClient, CompareFacesCommand } from "npm:@aws-sdk/client-rekognition@^3.0.0"; import { validateAWSCredentials, validateFile, createErrorResponse, createSuccessResponse } from "../shared/aws-utils.ts"; interface VerifyFaceResponse { success: true; matched: boolean; similarity: number; similarityThreshold: number; faceMatches: any[]; unmatchedFaces: any[]; idCardImageSize?: number; selfieImageSize?: number; processingTime?: number; confidence?: 'HIGH' | 'MEDIUM' | 'LOW'; } // Configuration constants const DEFAULT_SIMILARITY_THRESHOLD = 70; const HIGH_CONFIDENCE_THRESHOLD = 85; const MEDIUM_CONFIDENCE_THRESHOLD = 75; const MAX_FILE_SIZE = 15 * 1024 * 1024; // 15MB const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/jpg', 'image/png']; // Logger utility for structured logs const logger = { info: (message: string, data?: any) => { console.log(`[INFO] [${new Date().toISOString()}] [verify-face] ${message}`, data ? data : ''); }, warn: (message: string, data?: any) => { console.warn(`[WARN] [${new Date().toISOString()}] [verify-face] ${message}`, data ? data : ''); }, error: (message: string, error?: any) => { console.error(`[ERROR] [${new Date().toISOString()}] [verify-face] ${message}`, error ? error : ''); }, debug: (message: string, data?: any) => { console.debug(`[DEBUG] [${new Date().toISOString()}] [verify-face] ${message}`, data ? data : ''); }, success: (message: string, data?: any) => { console.log(`[SUCCESS] [${new Date().toISOString()}] [verify-face] ${message}`, data ? data : ''); } }; async function fileToUint8Array(file: File): Promise { const buffer = await file.arrayBuffer(); return new Uint8Array(buffer); } serve(async (req: Request): Promise => { const startTime = Date.now(); const requestId = crypto.randomUUID(); logger.info(`Starting face verification request [ID: ${requestId}]`); try { // Validate HTTP method if (req.method !== 'POST') { logger.warn(`Invalid HTTP method ${req.method} [ID: ${requestId}]`); return createErrorResponse('Method not allowed. Use POST', 405); } // Validate AWS credentials logger.debug(`Validating AWS credentials [ID: ${requestId}]`); const { credentials, error: credError } = validateAWSCredentials(); if (credError || !credentials) { logger.error(`AWS credentials validation failed [ID: ${requestId}]`, credError); return createErrorResponse(credError || 'AWS credentials not configured'); } logger.debug(`AWS Region: ${credentials.region} [ID: ${requestId}]`); // Initialize Rekognition client logger.debug(`Initializing Rekognition client [ID: ${requestId}]`); const rekognitionClient = new RekognitionClient({ region: credentials.region, credentials: { accessKeyId: credentials.accessKey, secretAccessKey: credentials.secretKey } }); // Parse multipart form data logger.debug(`Parsing form data [ID: ${requestId}]`); let formData: FormData; try { formData = await req.formData(); } catch (error) { logger.error(`Failed to parse form data [ID: ${requestId}]`, error); return createErrorResponse('Invalid form data. Expected multipart/form-data', 400); } // Get and validate both images const idCardImage = formData.get('idCard') as File | null; const selfieImage = formData.get('selfie') as File | null; // Validate IDCARD image logger.debug(`Validating IDCARD image [ID: ${requestId}]`); const idCardValidation = validateFile(idCardImage, 'IDCARD'); if (!idCardValidation.isValid) { logger.warn(`IDCARD image validation failed [ID: ${requestId}]`, idCardValidation.error); return createErrorResponse(idCardValidation.error || 'Invalid IDCARD image', 400); } // Validate selfie image logger.debug(`Validating selfie image [ID: ${requestId}]`); const selfieValidation = validateFile(selfieImage, 'selfie'); if (!selfieValidation.isValid) { logger.warn(`Selfie image validation failed [ID: ${requestId}]`, selfieValidation.error); return createErrorResponse(selfieValidation.error || 'Invalid selfie image', 400); } // Log image details logger.info(`Processing images [ID: ${requestId}]`, { idCard: { name: idCardImage!.name, size: `${(idCardImage!.size / 1024).toFixed(2)} KB`, type: idCardImage!.type }, selfie: { name: selfieImage!.name, size: `${(selfieImage!.size / 1024).toFixed(2)} KB`, type: selfieImage!.type } }); // Get similarity threshold from form data or use default const thresholdParam = formData.get('similarity_threshold'); let similarityThreshold = DEFAULT_SIMILARITY_THRESHOLD; if (thresholdParam) { const parsedThreshold = parseFloat(thresholdParam.toString()); if (!isNaN(parsedThreshold) && parsedThreshold >= 0 && parsedThreshold <= 100) { similarityThreshold = parsedThreshold; logger.debug(`Using custom similarity threshold: ${similarityThreshold}% [ID: ${requestId}]`); } else { logger.warn(`Invalid similarity threshold provided, using default: ${DEFAULT_SIMILARITY_THRESHOLD}% [ID: ${requestId}]`); } } // Convert images to Uint8Array logger.debug(`Converting images to binary format [ID: ${requestId}]`); let idCardBytes: Uint8Array, selfieBytes: Uint8Array; try { [idCardBytes, selfieBytes] = await Promise.all([ fileToUint8Array(idCardImage!), fileToUint8Array(selfieImage!) ]); } catch (error) { logger.error(`Failed to convert images to bytes [ID: ${requestId}]`, error); return createErrorResponse('Failed to process image data', 500); } // Create CompareFaces command logger.info(`Sending request to AWS Rekognition CompareFaces with threshold: ${similarityThreshold}% [ID: ${requestId}]`); const compareFacesCommand = new CompareFacesCommand({ SourceImage: { Bytes: idCardBytes }, TargetImage: { Bytes: selfieBytes }, SimilarityThreshold: similarityThreshold }); // Execute AWS Rekognition request let rekognitionResponse: any; try { rekognitionResponse = await rekognitionClient.send(compareFacesCommand); } catch (error) { logger.error(`AWS Rekognition request failed [ID: ${requestId}]`, error); // Handle specific AWS errors if (error.name === 'InvalidImageFormatException') { return createErrorResponse('Invalid image format. Please use JPEG or PNG format', 400); } else if (error.name === 'ImageTooLargeException') { return createErrorResponse('Image too large. Please reduce image size', 400); } else if (error.name === 'InvalidParameterException') { return createErrorResponse('Invalid parameters provided', 400); } return createErrorResponse('Failed to compare faces with AWS Rekognition', 500); } // Process results const processingTime = Date.now() - startTime; const matched = !!(rekognitionResponse.FaceMatches && rekognitionResponse.FaceMatches.length > 0); let highestSimilarity = 0; if (matched && rekognitionResponse.FaceMatches && rekognitionResponse.FaceMatches.length > 0) { highestSimilarity = Math.max(...rekognitionResponse.FaceMatches.map((match: any) => match.Similarity || 0)); } // Determine confidence level let confidence: 'HIGH' | 'MEDIUM' | 'LOW'; if (highestSimilarity >= HIGH_CONFIDENCE_THRESHOLD) { confidence = 'HIGH'; } else if (highestSimilarity >= MEDIUM_CONFIDENCE_THRESHOLD) { confidence = 'MEDIUM'; } else { confidence = 'LOW'; } // Log results in a structured format logger.success(`Face verification completed [ID: ${requestId}]`, { matched: matched, similarity: `${highestSimilarity.toFixed(2)}%`, confidence: confidence, processingTime: `${processingTime}ms`, faceMatchesCount: rekognitionResponse.FaceMatches?.length || 0, unmatchedFacesCount: rekognitionResponse.UnmatchedFaces?.length || 0 }); // Prepare response const response: VerifyFaceResponse = { success: true, matched: matched, similarity: parseFloat(highestSimilarity.toFixed(2)), similarityThreshold: similarityThreshold, faceMatches: rekognitionResponse.FaceMatches || [], unmatchedFaces: rekognitionResponse.UnmatchedFaces || [], idCardImageSize: idCardImage!.size, selfieImageSize: selfieImage!.size, processingTime, confidence }; return createSuccessResponse(response); } catch (error) { const processingTime = Date.now() - startTime; logger.error(`Unexpected error in verify-face function [ID: ${requestId}]`, error); logger.error(`Processing time before error: ${processingTime}ms [ID: ${requestId}]`); return createErrorResponse( "An unexpected error occurred while verifying faces", 500 ); } }); /* To invoke locally: 1. Run `supabase start` (see: https://supabase.com/docs/reference/cli/supabase-start) 2. Make an HTTP request: curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/verify-face' \ --header 'Authorization: Bearer [YOUR_ANON_KEY]' \ --form 'idCard=@"path/to/idCard.jpg"' \ --form 'selfie=@"path/to/selfie.jpg"' \ --form 'similarity_threshold=75' Example response: { "success": true, "matched": true, "similarity": 87.45, "similarityThreshold": 75, "faceMatches": [...], "unmatchedFaces": [], "idCardImageSize": 1048576, "selfieImageSize": 2097152, "processingTime": 1450, "confidence": "HIGH" } Environment Variables Required: - AWS_ACCESS_KEY_ID - AWS_SECRET_ACCESS_KEY - AWS_REGION (optional, defaults to us-east-1) */