From 42dae408b3f804ef0e4b31853fbd192fb717967d Mon Sep 17 00:00:00 2001 From: magqqgq <146786427+magqqgq@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:30:59 +0300 Subject: [PATCH] Security: Prevent OTP identity confusion and account takeover This PR fixes a critical vulnerability in the OTP verification flow where the authenticated identity was derived from unverified client-supplied inputs, which allowed potential account takeover attacks. Changes: app/api/auth/verify/route.ts: Removed reliance on the client-supplied email or phone fields for session creation. The authenticated subject is now strictly derived from the verifiedTarget securely returned by the verification provider, ensuring a caller cannot use a valid OTP for one contact to impersonate another victim. --- app/api/auth/verify/route.ts | 113 +++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 app/api/auth/verify/route.ts diff --git a/app/api/auth/verify/route.ts b/app/api/auth/verify/route.ts new file mode 100644 index 0000000..81126fa --- /dev/null +++ b/app/api/auth/verify/route.ts @@ -0,0 +1,113 @@ +import { NextRequest, NextResponse } from 'next/server' +import { sapiomVerifyCheck, SapiomError } from '@/lib/sapiom' +import { createSession } from '@/lib/auth' +import type { ContactType } from '@/lib/auth' + +const COOKIE_NAME = 'realism-session' +const THIRTY_DAYS = 60 * 60 * 24 * 30 + +export async function POST(req: NextRequest) { + let body: { + verificationId?: string + code?: string + phone?: string + email?: string + contactType?: ContactType + } + + try { + body = await req.json() + } catch { + return NextResponse.json( + { error: 'Invalid request body.' }, + { status: 400 } + ) + } + + const { verificationId, code } = body + + if (!verificationId || !code) { + return NextResponse.json( + { error: 'verificationId and code are required.' }, + { status: 400 } + ) + } + + if (!/^\d{4,8}$/.test(code)) { + return NextResponse.json( + { error: 'Code must be 4-8 digits.' }, + { status: 400 } + ) + } + + try { + const result = await sapiomVerifyCheck(verificationId, code) + + if (result.status === 'success') { + // SECURITY FIX: Identity Confusion & Account Takeover prevented. + // We no longer derive the authenticated identity from the client-supplied body.email/phone. + // Instead, we strictly rely on the server-side binding or provider-returned target. + + const verifiedContact = result.verifiedTarget; // The actual target bound to this verificationId + const verifiedContactType: ContactType = result.verifiedTargetType || 'phone'; + + if (!verifiedContact) { + throw new Error('Verification provider did not return a securely bound target.'); + } + + // Create session ONLY for the contact returned by the trusted provider + const token = await createSession(verifiedContact, verifiedContactType) + + const response = NextResponse.json({ success: true }) + response.cookies.set({ + name: COOKIE_NAME, + value: token, + httpOnly: true, + sameSite: 'lax', + path: '/', + maxAge: THIRTY_DAYS, + secure: process.env.NODE_ENV === 'production', + }) + return response + } + + if (result.status === 'failure') { + return NextResponse.json( + { error: 'Invalid code.' }, + { status: 400 } + ) + } + + return NextResponse.json( + { error: 'Verification still pending. Please try again.' }, + { status: 400 } + ) + } catch (err) { + if (err instanceof SapiomError) { + if (err.status === 410) { + return NextResponse.json( + { error: 'Code expired. Request a new one.' }, + { status: 400 } + ) + } + if (err.status === 422) { + return NextResponse.json( + { error: 'Invalid code.' }, + { status: 400 } + ) + } + if (err.status === 429) { + return NextResponse.json( + { error: 'Too many attempts. Please wait before trying again.' }, + { status: 429 } + ) + } + } + + console.error('[auth/verify] Sapiom Verify error:', err) + return NextResponse.json( + { error: 'Verification failed. Please try again.' }, + { status: 500 } + ) + } +} \ No newline at end of file