Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions app/api/auth/verify/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
)
}
}