import { createServiceClient } from "../_shared/security.ts"; import { getClientIp, getCorsHeaders, hashText, jsonResponse, preflightResponse, readJsonBody, requireRateLimit, } from "../_shared/security.ts"; const MAX_BODY_BYTES = 8 * 1024; const OTP_EXPIRY_SECONDS = 600; // 10 minutes const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); Deno.serve(async (request) => { if (request.method === "OPTIONS") { return preflightResponse(request, "public"); } if (request.method !== "POST") { return jsonResponse({ ok: false, error: "Method not allowed" }, 405); } const corsHeaders = getCorsHeaders(request, "public"); if (!corsHeaders) { return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); } try { const { body } = await readJsonBody<{ email?: string; otp?: string }>(request, { maxBytes: MAX_BODY_BYTES, }); const email = String(body.email || "").trim().toLowerCase(); const otp = String(body.otp || "").trim(); if (!email || !isValidEmail(email)) { return jsonResponse({ ok: false, error: "Valid email is required" }, 400, corsHeaders); } if (!otp || otp.length < 4 || otp.length > 12) { return jsonResponse({ ok: false, error: "Valid OTP is required" }, 400, corsHeaders); } const supabase = createServiceClient(); const emailHash = await hashText(email); const ipHash = await hashText(getClientIp(request)); await requireRateLimit(supabase, { scope: "otp-verify", key: `${ipHash}:${emailHash}`, maxCount: 5, windowSeconds: 600, blockSeconds: 1800, }); // 1. Find the most recent unverified OTP for this email const { data: otpRecords, error: fetchError } = await supabase .from("login_otps") .select("*") .eq("email", email) .eq("verified", false) .order("created_at", { ascending: false }) .limit(1); if (fetchError || !otpRecords || otpRecords.length === 0) { return jsonResponse({ ok: false, error: "Неверный или просроченный код" }, 400, corsHeaders); } const otpRecord = otpRecords[0]; // 2. Check expiry (10 minutes) const createdAt = new Date(otpRecord.created_at); const now = new Date(); const elapsedSeconds = (now.getTime() - createdAt.getTime()) / 1000; if (elapsedSeconds > OTP_EXPIRY_SECONDS) { await supabase.from("login_otps").delete().eq("id", otpRecord.id); return jsonResponse({ ok: false, error: "Код истёк. Запросите новый." }, 400, corsHeaders); } // 3. Verify OTP — compare hash (new) with fallback to plaintext (old records) const submittedOtpHash = await hashText(otp); let otpMatches = false; if (otpRecord.otp_code_hash) { // New flow: compare SHA-256 hashes otpMatches = otpRecord.otp_code_hash === submittedOtpHash; } else if (otpRecord.otp_code) { // Legacy fallback: plaintext comparison for old records otpMatches = otpRecord.otp_code === otp; } if (!otpMatches) { return jsonResponse({ ok: false, error: "Неверный код" }, 400, corsHeaders); } // 4. Mark as verified and clear plaintext if present await supabase .from("login_otps") .update({ verified: true, otp_code: "" }) .eq("id", otpRecord.id); // Delete all other unverified OTPs for this email await supabase .from("login_otps") .delete() .eq("email", email) .eq("verified", false); // 5. Find user by email to get user_id const { data: users } = await supabase .from("users") .select("id, name, roles(name)") .eq("email", email) .limit(1); if (!users || users.length === 0) { return jsonResponse({ ok: false, error: "Пользователь не найден" }, 400, corsHeaders); } const userId = users[0].id; const userName = users[0].name || null; const userRole = users[0].roles?.name || null; // Update the login_otps record with user info await supabase .from("login_otps") .update({ name: userName, role: userRole }) .eq("id", otpRecord.id); // 6. Create session using Supabase admin API const { data: linkData, error: linkError } = await supabase.auth.admin.generateLink({ type: "magiclink", email, }); if (linkError || !linkData) { console.error("generateLink error:", linkError); return jsonResponse({ ok: false, error: "Ошибка авторизации" }, 500, corsHeaders); } const generatedLink = linkData as any; const tokenHash = generatedLink.properties?.hashed_token || generatedLink.properties?.token_hash; if (!tokenHash) { console.error("No token in generateLink response"); return jsonResponse({ ok: false, error: "Ошибка авторизации" }, 500, corsHeaders); } const { data: verifyData, error: verifyError } = await supabase.auth.verifyOtp({ type: "magiclink", token_hash: tokenHash, }); if (verifyError) { console.error("verifyOtp error:", verifyError); return jsonResponse({ ok: false, error: "Ошибка авторизации" }, 500, corsHeaders); } const session = verifyData.session; const user = verifyData.user; return jsonResponse( { ok: true, session: session || null, user: user || null, }, 200, corsHeaders, ); } catch (error) { if (error instanceof Error && "status" in error) { const httpError = error as { status: number; message: string }; return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders); } return jsonResponse( { ok: false, error: error instanceof Error ? error.message : "Unexpected error", }, 500, corsHeaders, ); } });