diff --git a/ballotiq/src/app/api/gemini/assess/route.ts b/ballotiq/src/app/api/gemini/assess/route.ts index 8680d98..ebb8e1f 100644 --- a/ballotiq/src/app/api/gemini/assess/route.ts +++ b/ballotiq/src/app/api/gemini/assess/route.ts @@ -5,10 +5,11 @@ */ import { NextRequest, NextResponse } from 'next/server'; +import { withAuth } from '@/lib/security/auth'; import { analyzeAssessment } from '@/lib/gemini/assessment'; import type { AssessmentAnswer } from '@/types'; -export async function POST(req: NextRequest) { +export const POST = withAuth(async (req: NextRequest) => { try { const body = await req.json() as { answers: AssessmentAnswer; @@ -41,4 +42,4 @@ export async function POST(req: NextRequest) { { status: 500 } ); } -} +}); diff --git a/ballotiq/src/app/api/gemini/assistant/route.ts b/ballotiq/src/app/api/gemini/assistant/route.ts index 28c9e91..089bfb1 100644 --- a/ballotiq/src/app/api/gemini/assistant/route.ts +++ b/ballotiq/src/app/api/gemini/assistant/route.ts @@ -7,8 +7,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAssistantResponse } from '@/lib/assistant/hybridAssistant'; import type { ChatMessage, ElectionStep, UserContext } from '@/types'; +import { withAuth } from '@/lib/security/auth'; -export async function POST(req: NextRequest) { +export const POST = withAuth(async (req: NextRequest) => { try { const body = await req.json() as { question: string; @@ -38,4 +39,4 @@ export async function POST(req: NextRequest) { { status: 500 } ); } -} +}); diff --git a/ballotiq/src/app/api/gemini/guide/route.ts b/ballotiq/src/app/api/gemini/guide/route.ts index 609828d..2808888 100644 --- a/ballotiq/src/app/api/gemini/guide/route.ts +++ b/ballotiq/src/app/api/gemini/guide/route.ts @@ -5,10 +5,11 @@ */ import { NextRequest, NextResponse } from 'next/server'; +import { withAuth } from '@/lib/security/auth'; import { generatePersonalizedGuide, generatePersonalizedGuideStream } from '@/lib/gemini/guide'; import type { KnowledgeLevel } from '@/types'; -export async function POST(req: NextRequest) { +export const POST = withAuth(async (req: NextRequest) => { try { const body = await req.json() as { countryCode: string; @@ -75,4 +76,4 @@ export async function POST(req: NextRequest) { { status: 500 } ); } -} +}); diff --git a/ballotiq/src/app/api/gemini/insight/route.ts b/ballotiq/src/app/api/gemini/insight/route.ts index 2520fd8..5e23982 100644 --- a/ballotiq/src/app/api/gemini/insight/route.ts +++ b/ballotiq/src/app/api/gemini/insight/route.ts @@ -5,10 +5,11 @@ */ import { NextRequest, NextResponse } from 'next/server'; +import { withAuth } from '@/lib/security/auth'; import { generatePerformanceInsight } from '@/lib/gemini/quiz'; import type { KnowledgeLevel, QuizQuestion, QuizResult } from '@/types'; -export async function POST(req: NextRequest) { +export const POST = withAuth(async (req: NextRequest) => { try { const body = await req.json() as { results: QuizResult[]; @@ -45,4 +46,4 @@ export async function POST(req: NextRequest) { { status: 500 } ); } -} +}); diff --git a/ballotiq/src/app/api/gemini/micro-quiz/route.ts b/ballotiq/src/app/api/gemini/micro-quiz/route.ts index 9688842..8d43fcf 100644 --- a/ballotiq/src/app/api/gemini/micro-quiz/route.ts +++ b/ballotiq/src/app/api/gemini/micro-quiz/route.ts @@ -5,10 +5,11 @@ */ import { NextRequest, NextResponse } from 'next/server'; +import { withAuth } from '@/lib/security/auth'; import { generateMicroQuiz } from '@/lib/gemini/quiz'; import type { ElectionStep, KnowledgeLevel } from '@/types'; -export async function POST(req: NextRequest) { +export const POST = withAuth(async (req: NextRequest) => { try { const body = await req.json() as { step: ElectionStep; @@ -41,4 +42,4 @@ export async function POST(req: NextRequest) { { status: 500 } ); } -} +}); diff --git a/ballotiq/src/app/api/gemini/quiz/route.ts b/ballotiq/src/app/api/gemini/quiz/route.ts index 6858bbf..f7c21f2 100644 --- a/ballotiq/src/app/api/gemini/quiz/route.ts +++ b/ballotiq/src/app/api/gemini/quiz/route.ts @@ -5,10 +5,11 @@ */ import { NextRequest, NextResponse } from 'next/server'; +import { withAuth } from '@/lib/security/auth'; import { generatePersonalizedQuiz } from '@/lib/gemini/quiz'; import type { ElectionStep, KnowledgeLevel } from '@/types'; -export async function POST(req: NextRequest) { +export const POST = withAuth(async (req: NextRequest) => { try { const body = await req.json() as { completedSteps: ElectionStep[]; @@ -44,4 +45,4 @@ export async function POST(req: NextRequest) { { status: 500 } ); } -} +}); diff --git a/ballotiq/src/app/api/gemini/re-explain/route.ts b/ballotiq/src/app/api/gemini/re-explain/route.ts index 18c8e9c..a1baa78 100644 --- a/ballotiq/src/app/api/gemini/re-explain/route.ts +++ b/ballotiq/src/app/api/gemini/re-explain/route.ts @@ -5,10 +5,11 @@ */ import { NextRequest, NextResponse } from 'next/server'; +import { withAuth } from '@/lib/security/auth'; import { reExplainConcept } from '@/lib/gemini/assistant'; import type { ElectionStep, KnowledgeLevel } from '@/types'; -export async function POST(req: NextRequest) { +export const POST = withAuth(async (req: NextRequest) => { try { const body = await req.json() as { step: ElectionStep; @@ -45,4 +46,4 @@ export async function POST(req: NextRequest) { { status: 500 } ); } -} +}); diff --git a/ballotiq/src/lib/security/auth.ts b/ballotiq/src/lib/security/auth.ts new file mode 100644 index 0000000..4556032 --- /dev/null +++ b/ballotiq/src/lib/security/auth.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from 'next/server'; +import * as admin from 'firebase-admin'; + +// Initialize Firebase Admin if not already initialized +if (!admin.apps.length) { + try { + admin.initializeApp({ + credential: admin.credential.applicationDefault(), + }); + } catch (error) { + console.error('Firebase admin initialization error', error); + } +} + +/** + * Validates the Authorization header and returns the decoded Firebase ID token. + * + * @param req NextRequest + * @returns DecodedIdToken if valid, null if invalid or missing + */ +export async function verifyAuthToken(req: NextRequest) { + try { + const authHeader = req.headers.get('Authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return null; + } + + const token = authHeader.split('Bearer ')[1]; + const decodedToken = await admin.auth().verifyIdToken(token); + return decodedToken; + } catch (error) { + console.error('Error verifying auth token', error); + return null; + } +} + +/** + * Middleware wrapper for Next.js API routes to enforce authentication. + * If the request is a state-mutating method (POST, PUT, PATCH, DELETE) and lacks a valid token, + * it returns a 401 Unauthorized response. + * + * @param handler The API route handler function + * @returns A wrapped API route handler + */ +export function withAuth(handler: (req: NextRequest, ...args: any[]) => Promise) { + return async (req: NextRequest, ...args: any[]) => { + // Check if the request is state-mutating + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) { + const decodedToken = await verifyAuthToken(req); + if (!decodedToken) { + return NextResponse.json( + { error: 'Unauthorized: Missing or invalid authentication token.' }, + { status: 401 } + ); + } + + // Inject user info into headers or pass to handler if needed + // (Next.js App router doesn't allow mutating req easily, but we know it's verified) + } + + return handler(req, ...args); + }; +}