From da32d1882f9a44ca1f79fcb2a6a5aa3a7d2e7cc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cmkczarkowski=E2=80=9D?= Date: Thu, 23 Oct 2025 10:25:35 +0200 Subject: [PATCH] refactor: extract and reuse helpers in api --- src/pages/api/auth/login.ts | 204 ++++++++---------- src/pages/api/auth/resend-verification.ts | 239 ++++++++++------------ src/pages/api/auth/reset-password.ts | 79 +++---- src/pages/api/auth/signup.ts | 172 ++++++---------- src/pages/api/auth/update-password.ts | 137 +++++-------- src/pages/api/guards/withAuth.ts | 67 ++++++ src/pages/api/guards/withCaptcha.ts | 113 ++++++++++ src/pages/api/guards/withFeatureFlag.ts | 32 +++ src/pages/api/utils/apiResponse.ts | 152 ++++++++++++++ src/pages/api/utils/supabaseHelpers.ts | 59 ++++++ 10 files changed, 757 insertions(+), 497 deletions(-) create mode 100644 src/pages/api/guards/withAuth.ts create mode 100644 src/pages/api/guards/withCaptcha.ts create mode 100644 src/pages/api/guards/withFeatureFlag.ts create mode 100644 src/pages/api/utils/apiResponse.ts create mode 100644 src/pages/api/utils/supabaseHelpers.ts diff --git a/src/pages/api/auth/login.ts b/src/pages/api/auth/login.ts index 2c040a5..6766885 100644 --- a/src/pages/api/auth/login.ts +++ b/src/pages/api/auth/login.ts @@ -1,128 +1,96 @@ import type { APIRoute } from 'astro'; -import { isFeatureEnabled } from '@/features/featureFlags'; -import { createSupabaseServerInstance } from '@/db/supabase.client'; -import { verifyCaptcha } from '@/services/captcha'; -import { CF_CAPTCHA_SECRET_KEY } from 'astro:env/server'; - -export const POST: APIRoute = async ({ request, cookies }) => { - // Check if auth feature is enabled - if (!isFeatureEnabled('auth')) { - return new Response(JSON.stringify({ error: 'Authentication is currently disabled' }), { - status: 403, - }); - } - - try { - const { email, password, captchaToken } = (await request.json()) as { - email: string; - password: string; - captchaToken: string; - }; - - if (!email || !password || !captchaToken) { - return new Response( - JSON.stringify({ error: 'Email, password, and captcha token are required' }), - { - status: 400, - }, - ); - } - - // Verify captcha on backend - const requestorIp = request.headers.get('cf-connecting-ip') || ''; - const captchaResult = await verifyCaptcha(CF_CAPTCHA_SECRET_KEY, captchaToken, requestorIp); - - if (!captchaResult.success) { - return new Response( - JSON.stringify({ - error: 'Security verification failed. Please try again.', - errorCodes: captchaResult['error-codes'], - }), - { status: 400 }, - ); - } - - const supabase = createSupabaseServerInstance({ cookies, headers: request.headers }); - - const { data, error } = await supabase.auth.signInWithPassword({ - email, - password, - }); - - if (error) { - // Check if error is due to unconfirmed email - if (error.message.toLowerCase().includes('email not confirmed')) { - // Auto-resend verification email - const requestorIp = request.headers.get('cf-connecting-ip') || ''; - - // Check rate limit first - const { data: rateLimitResult, error: rateLimitError } = await supabase.rpc( - 'check_and_log_verification_request', - { - p_email: email.toLowerCase(), - p_ip_address: requestorIp, - }, - ); +import { withFeatureFlag } from '../guards/withFeatureFlag'; +import { withCaptcha } from '../guards/withCaptcha'; +import { successResponse, errorResponse, validationError } from '../utils/apiResponse'; +import { createServerClient, getClientIp, getOrigin } from '../utils/supabaseHelpers'; + +export const POST: APIRoute = withFeatureFlag( + 'auth', + withCaptcha<{ email: string; password: string; captchaToken: string }>( + async ({ body, request, cookies }) => { + const { email, password } = body; + + if (!email || !password) { + return validationError('Email and password are required'); + } - if (rateLimitError) { - console.error('Rate limit check error:', rateLimitError); - return new Response( - JSON.stringify({ - error: - 'Your email address has not been verified. Please check your email or request a new verification link.', - type: 'email_not_confirmed', - email: email, - }), - { status: 400 }, + const supabase = createServerClient({ cookies, headers: request.headers }); + + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password, + }); + + if (error) { + // Check if error is due to unconfirmed email + if (error.message.toLowerCase().includes('email not confirmed')) { + // Auto-resend verification email + const requestorIp = getClientIp(request); + + // Check rate limit first + const { data: rateLimitResult, error: rateLimitCheckError } = await supabase.rpc( + 'check_and_log_verification_request', + { + p_email: email.toLowerCase(), + p_ip_address: requestorIp, + }, ); - } - // Check if rate limited - if (rateLimitResult && !rateLimitResult.allowed) { - const retryAfter = rateLimitResult.retry_after || 3600; - const minutes = Math.ceil(retryAfter / 60); - - return new Response( - JSON.stringify({ - error: `Your email is not verified. We've already sent verification emails recently. You can request another email in ${minutes} minute${minutes > 1 ? 's' : ''}.`, - type: 'email_not_confirmed_rate_limited', + if (rateLimitCheckError) { + console.error('Rate limit check error:', rateLimitCheckError); + return errorResponse( + 'Your email address has not been verified. Please check your email or request a new verification link.', + 400, + { + type: 'email_not_confirmed', + email: email, + }, + ); + } + + // Check if rate limited + if (rateLimitResult && !rateLimitResult.allowed) { + const retryAfter = rateLimitResult.retry_after || 3600; + const minutes = Math.ceil(retryAfter / 60); + + return errorResponse( + `Your email is not verified. We've already sent verification emails recently. You can request another email in ${minutes} minute${minutes > 1 ? 's' : ''}.`, + 400, + { + type: 'email_not_confirmed_rate_limited', + email: email, + retryAfter: retryAfter, + }, + ); + } + + // Send verification email + const { error: resendError } = await supabase.auth.resend({ + type: 'signup', + email: email.toLowerCase(), + options: { + emailRedirectTo: `${getOrigin(request)}/auth/login`, + }, + }); + + if (resendError) { + console.error('Error resending verification email:', resendError); + } + + return errorResponse( + 'Your email address has not been verified. We sent you a verification email - please check your inbox.', + 400, + { + type: 'email_not_confirmed', email: email, - retryAfter: retryAfter, - }), - { status: 400 }, + }, ); } - // Send verification email - const { error: resendError } = await supabase.auth.resend({ - type: 'signup', - email: email.toLowerCase(), - options: { - emailRedirectTo: `${new URL(request.url).origin}/auth/login`, - }, - }); - - if (resendError) { - console.error('Error resending verification email:', resendError); - } - - return new Response( - JSON.stringify({ - error: - 'Your email address has not been verified. We sent you a verification email - please check your inbox.', - type: 'email_not_confirmed', - email: email, - }), - { status: 400 }, - ); + return errorResponse(error.message, 400); } - return new Response(JSON.stringify({ error: error.message }), { status: 400 }); - } - - return new Response(JSON.stringify({ user: data.user }), { status: 200 }); - } catch (err) { - console.error('Login error:', err); - return new Response(JSON.stringify({ error: 'An unexpected error occurred' }), { status: 500 }); - } -}; + return successResponse({ user: data.user }); + }, + ), +); diff --git a/src/pages/api/auth/resend-verification.ts b/src/pages/api/auth/resend-verification.ts index ec09925..e2a11c1 100644 --- a/src/pages/api/auth/resend-verification.ts +++ b/src/pages/api/auth/resend-verification.ts @@ -1,155 +1,126 @@ import type { APIRoute } from 'astro'; -import { createSupabaseServerInstance } from '../../../db/supabase.client'; -import { isFeatureEnabled } from '../../../features/featureFlags'; -import { verifyCaptcha } from '../../../services/captcha'; -import { CF_CAPTCHA_SECRET_KEY } from 'astro:env/server'; +import { withFeatureFlag } from '../guards/withFeatureFlag'; +import { withCaptcha } from '../guards/withCaptcha'; +import { + successResponse, + serverError, + validationError, + serviceUnavailableError, + rateLimitError, +} from '../utils/apiResponse'; +import { createServerClient, getClientIp, getOrigin, getUserAgent } from '../utils/supabaseHelpers'; export const prerender = false; -export const POST: APIRoute = async ({ request, cookies }) => { - // Check if auth feature is enabled - if (!isFeatureEnabled('auth')) { - return new Response(JSON.stringify({ error: 'Authentication is currently disabled' }), { - status: 403, - }); - } - - try { - const { email, captchaToken } = (await request.json()) as { - email: string; - captchaToken: string; - }; +export const POST: APIRoute = withFeatureFlag( + 'auth', + withCaptcha<{ email: string; captchaToken: string }>(async ({ body, request, cookies }) => { + try { + const { email } = body; - if (!email || !captchaToken) { - return new Response(JSON.stringify({ error: 'Email and captcha token are required' }), { - status: 400, - }); - } + if (!email) { + return validationError('Email is required'); + } - // Verify captcha - const requestorIp = request.headers.get('cf-connecting-ip') || ''; - const captchaResult = await verifyCaptcha(CF_CAPTCHA_SECRET_KEY, captchaToken, requestorIp); + const supabase = createServerClient({ cookies, headers: request.headers }); - if (!captchaResult.success) { - return new Response( - JSON.stringify({ - error: 'Captcha verification failed', - errorCodes: captchaResult['error-codes'], - }), - { status: 400 }, + // Check rate limiting using database function (SECURITY DEFINER) + const requestorIp = getClientIp(request); + const { data: rateLimitResult, error: rateLimitCheckError } = await supabase.rpc( + 'check_and_log_verification_request', + { + p_email: email.toLowerCase(), + p_ip_address: requestorIp, + }, ); - } - const supabase = createSupabaseServerInstance({ cookies, headers: request.headers }); - - // Check rate limiting using database function (SECURITY DEFINER) - const { data: rateLimitResult, error: rateLimitError } = await supabase.rpc( - 'check_and_log_verification_request', - { - p_email: email.toLowerCase(), - p_ip_address: requestorIp, - }, - ); + if (rateLimitCheckError) { + // Detailed logging for developers + console.error('Rate limit check error:', { + error: rateLimitCheckError, + code: rateLimitCheckError.code, + message: rateLimitCheckError.message, + details: rateLimitCheckError.details, + hint: rateLimitCheckError.hint, + }); + + // Check if function doesn't exist (migration not applied) + if ( + rateLimitCheckError.code === '42883' || + rateLimitCheckError.message?.includes('function') + ) { + console.warn( + '⚠️ Database function "check_and_log_verification_request" not found. ' + + 'Run: npx supabase db push', + ); + } + + // User-friendly error message + return serviceUnavailableError( + 'Service temporarily unavailable. Please try again in a few moments.', + 10, + ); + } - if (rateLimitError) { - // Detailed logging for developers - console.error('Rate limit check error:', { - error: rateLimitError, - code: rateLimitError.code, - message: rateLimitError.message, - details: rateLimitError.details, - hint: rateLimitError.hint, - }); + // Check if rate limited + if (rateLimitResult && !rateLimitResult.allowed) { + const retryAfter = rateLimitResult.retry_after || 3600; + const minutes = Math.ceil(retryAfter / 60); - // Check if function doesn't exist (migration not applied) - if (rateLimitError.code === '42883' || rateLimitError.message?.includes('function')) { - console.warn( - '⚠️ Database function "check_and_log_verification_request" not found. ' + - 'Run: npx supabase db push', + return rateLimitError( + `Too many verification email requests. You can request another email in ${minutes} minute${minutes > 1 ? 's' : ''}.`, + retryAfter, ); } - // User-friendly error message - return new Response( - JSON.stringify({ - error: 'Service temporarily unavailable. Please try again in a few moments.', - type: 'service_error', - developerMessage: import.meta.env.DEV ? rateLimitError.message : undefined, - }), - { - status: 503, // Service Unavailable (not Internal Server Error) - headers: { - 'Content-Type': 'application/json', - 'Retry-After': '10', // Suggest retry after 10 seconds - }, + // Attempt to send verification email + // Note: Supabase will silently handle cases where: + // - Email doesn't exist + // - Email is already confirmed + // This prevents email enumeration attacks + const { error: resendError } = await supabase.auth.resend({ + type: 'signup', + email: email.toLowerCase(), + options: { + emailRedirectTo: `${getOrigin(request)}/auth/verify`, }, - ); - } - - // Check if rate limited - if (rateLimitResult && !rateLimitResult.allowed) { - const retryAfter = rateLimitResult.retry_after || 3600; - const minutes = Math.ceil(retryAfter / 60); - - return new Response( - JSON.stringify({ - error: `Too many verification email requests. You can request another email in ${minutes} minute${minutes > 1 ? 's' : ''}.`, - type: 'rate_limit', - retryAfter: retryAfter, - }), - { status: 429 }, - ); - } - - // Attempt to send verification email - // Note: Supabase will silently handle cases where: - // - Email doesn't exist - // - Email is already confirmed - // This prevents email enumeration attacks - const { error: resendError } = await supabase.auth.resend({ - type: 'signup', - email: email.toLowerCase(), - options: { - emailRedirectTo: `${new URL(request.url).origin}/auth/verify`, - }, - }); - - // FIX 6: Log email send for monitoring (non-blocking) - try { - const { error: logError } = await supabase.rpc('log_email_send', { - p_email: email.toLowerCase(), - p_type: 'resend_verification', - p_ip_address: requestorIp, - p_user_agent: request.headers.get('user-agent'), - p_request_path: '/api/auth/resend-verification', - p_user_id: null, // We don't have user ID in this endpoint - p_status: resendError ? 'failed' : 'sent', - p_error_message: resendError?.message || null, }); - if (logError) console.error('Failed to log email send:', logError); - } catch (logException) { - console.error('Email logging exception (function may not exist yet):', logException); - } - // Always return success to prevent email enumeration - // Even if there was an error, we don't want to reveal whether the email exists - if (resendError) { - console.error('Resend verification error:', resendError); - // Still return success to user - } + // FIX 6: Log email send for monitoring (non-blocking) + try { + const { error: logError } = await supabase.rpc('log_email_send', { + p_email: email.toLowerCase(), + p_type: 'resend_verification', + p_ip_address: requestorIp, + p_user_agent: getUserAgent(request), + p_request_path: '/api/auth/resend-verification', + p_user_id: null, // We don't have user ID in this endpoint + p_status: resendError ? 'failed' : 'sent', + p_error_message: resendError?.message || null, + }); + if (logError) console.error('Failed to log email send:', logError); + } catch (logException) { + console.error('Email logging exception (function may not exist yet):', logException); + } + + // Always return success to prevent email enumeration + // Even if there was an error, we don't want to reveal whether the email exists + if (resendError) { + console.error('Resend verification error:', resendError); + // Still return success to user + } - return new Response( - JSON.stringify({ + return successResponse({ success: true, message: 'If an unverified account exists with this email, a verification link has been sent.', - }), - { status: 200 }, - ); - } catch (err) { - console.error('Resend verification error:', err); - return new Response(JSON.stringify({ error: 'An unexpected error occurred' }), { - status: 500, - }); - } -}; + }); + } catch (err) { + console.error('Resend verification error:', err); + return serverError( + 'An unexpected error occurred', + err instanceof Error ? err.message : undefined, + ); + } + }), +); diff --git a/src/pages/api/auth/reset-password.ts b/src/pages/api/auth/reset-password.ts index 4fd438d..018e86c 100644 --- a/src/pages/api/auth/reset-password.ts +++ b/src/pages/api/auth/reset-password.ts @@ -1,57 +1,38 @@ import type { APIRoute } from 'astro'; -import { createSupabaseAdminInstance } from '@/db/supabase.client'; -import { verifyCaptcha } from '@/services/captcha'; -import { CF_CAPTCHA_SECRET_KEY } from 'astro:env/server'; +import { withCaptcha } from '../guards/withCaptcha'; +import { successResponse, serverError, validationError } from '../utils/apiResponse'; +import { createAdminClient, getOrigin } from '../utils/supabaseHelpers'; -export const POST: APIRoute = async ({ request, url, cookies }) => { - try { - const { email, captchaToken } = (await request.json()) as { - email: string; - captchaToken: string; - }; +export const POST: APIRoute = withCaptcha<{ email: string; captchaToken: string }>( + async ({ body, request, cookies }) => { + try { + const { email } = body; - if (!email || !captchaToken) { - return new Response(JSON.stringify({ error: 'Email and captcha token are required' }), { - status: 400, - }); - } - - // Verify captcha on backend - const requestorIp = request.headers.get('cf-connecting-ip') || ''; - const captchaResult = await verifyCaptcha(CF_CAPTCHA_SECRET_KEY, captchaToken, requestorIp); - - if (!captchaResult.success) { - return new Response( - JSON.stringify({ - error: 'Security verification failed. Please try again.', - errorCodes: captchaResult['error-codes'], - }), - { status: 400 }, - ); - } + if (!email) { + return validationError('Email is required'); + } - const supabase = createSupabaseAdminInstance({ cookies, headers: request.headers }); + const supabase = createAdminClient({ cookies, headers: request.headers }); - const { error } = await supabase.auth.resetPasswordForEmail(email, { - redirectTo: `${url.origin}/auth/update-password`, - }); + const { error } = await supabase.auth.resetPasswordForEmail(email, { + redirectTo: `${getOrigin(request)}/auth/update-password`, + }); - // Don't disclose whether the email exists or not for security reasons. - // Always return a success response. - if (error) { - console.error('Password reset error:', error.message); - } + // Don't disclose whether the email exists or not for security reasons. + // Always return a success response. + if (error) { + console.error('Password reset error:', error.message); + } - return new Response( - JSON.stringify({ message: 'Password reset instructions sent if email is valid' }), - { status: 200 }, - ); - } catch (err) { - console.error('Reset password endpoint error:', err); - // Handle JSON parsing errors or other unexpected issues - if (err instanceof SyntaxError) { - return new Response(JSON.stringify({ error: 'Invalid request body' }), { status: 400 }); + return successResponse({ + message: 'Password reset instructions sent if email is valid', + }); + } catch (err) { + console.error('Reset password endpoint error:', err); + return serverError( + 'An unexpected error occurred', + err instanceof Error ? err.message : undefined, + ); } - return new Response(JSON.stringify({ error: 'An unexpected error occurred' }), { status: 500 }); - } -}; + }, +); diff --git a/src/pages/api/auth/signup.ts b/src/pages/api/auth/signup.ts index d6d7058..9c1094c 100644 --- a/src/pages/api/auth/signup.ts +++ b/src/pages/api/auth/signup.ts @@ -1,64 +1,44 @@ import type { APIRoute } from 'astro'; +import { withFeatureFlag } from '../guards/withFeatureFlag'; +import { withCaptcha } from '../guards/withCaptcha'; import { - createSupabaseAdminInstance, - createSupabaseServerInstance, -} from '../../../db/supabase.client'; -import { isFeatureEnabled } from '../../../features/featureFlags'; + successResponse, + errorResponse, + validationError, + rateLimitError, +} from '../utils/apiResponse'; +import { + createAdminClient, + createServerClient, + getClientIp, + getOrigin, + getUserAgent, +} from '../utils/supabaseHelpers'; import { PRIVACY_POLICY_VERSION } from '../../../pages/privacy/privacyPolicyVersion'; import { redeemInvite } from '../../../services/prompt-library/invites'; -import { verifyCaptcha } from '../../../services/captcha'; -import { CF_CAPTCHA_SECRET_KEY } from 'astro:env/server'; - -export const POST: APIRoute = async ({ request, cookies }) => { - // Check if auth feature is enabled - if (!isFeatureEnabled('auth')) { - return new Response(JSON.stringify({ error: 'Authentication is currently disabled' }), { - status: 403, - }); - } - - // Store email at top level for error handler access - let userEmail: string | undefined; - - try { - const { email, password, privacyPolicyConsent, inviteToken, captchaToken } = - (await request.json()) as { - email: string; - password: string; - privacyPolicyConsent: boolean; - inviteToken?: string; - captchaToken: string; - }; - - userEmail = email; // Store for error handler - if (!email || !password || !privacyPolicyConsent || !captchaToken) { - return new Response( - JSON.stringify({ - error: 'Email, password, privacy policy consent, and captcha token are required', - }), - { status: 400 }, - ); - } +type SignupBody = { + email: string; + password: string; + privacyPolicyConsent: boolean; + inviteToken?: string; + captchaToken: string; +}; - // Verify captcha on backend - const requestorIp = request.headers.get('cf-connecting-ip') || ''; - const captchaResult = await verifyCaptcha(CF_CAPTCHA_SECRET_KEY, captchaToken, requestorIp); +export const POST: APIRoute = withFeatureFlag( + 'auth', + withCaptcha(async ({ body, request, cookies, captchaToken }) => { + const { email, password, privacyPolicyConsent, inviteToken } = body; - if (!captchaResult.success) { - return new Response( - JSON.stringify({ - error: 'Security verification failed. Please try again.', - errorCodes: captchaResult['error-codes'], - }), - { status: 400 }, - ); + if (!email || !password || !privacyPolicyConsent) { + return validationError('Email, password, and privacy policy consent are required'); } - const supabase = createSupabaseAdminInstance({ cookies, headers: request.headers }); + const supabase = createAdminClient({ cookies, headers: request.headers }); // FIX 3: Check for duplicate signup request (prevents network retry duplicates) // Gracefully handle if function doesn't exist yet (gradual rollout) + const requestorIp = getClientIp(request); try { // Use Web Crypto API (compatible with Cloudflare Workers) const encoder = new TextEncoder(); @@ -83,12 +63,9 @@ export const POST: APIRoute = async ({ request, cookies }) => { } else if (dedupResult?.is_duplicate) { const secondsAgo = dedupResult.seconds_ago || 0; const remainingSeconds = Math.max(120 - secondsAgo, 0); - return new Response( - JSON.stringify({ - error: `Signup request already ${dedupResult.status}. Please wait ${remainingSeconds} seconds before trying again.`, - type: 'duplicate_request', - }), - { status: 429 }, + return rateLimitError( + `Signup request already ${dedupResult.status}. Please wait ${remainingSeconds} seconds before trying again.`, + remainingSeconds, ); } } catch (dedupException) { @@ -138,37 +115,29 @@ export const POST: APIRoute = async ({ request, cookies }) => { // Handle the action returned by the atomic check if (signupCheck.action === 'error') { - return new Response( - JSON.stringify({ - error: signupCheck.message, - type: signupCheck.type, - }), - { status: 400 }, - ); + return errorResponse(signupCheck.message || 'Signup check failed', 400, { + type: signupCheck.type, + }); } if (signupCheck.action === 'rate_limited') { const retryAfter = signupCheck.retry_after || 3600; const minutes = Math.ceil(retryAfter / 60); - return new Response( - JSON.stringify({ - error: `Verification email already sent recently. Please check your inbox or wait ${minutes} minute${minutes > 1 ? 's' : ''}.`, - type: 'rate_limit', - retryAfter: retryAfter, - }), - { status: 429 }, + return rateLimitError( + `Verification email already sent recently. Please check your inbox or wait ${minutes} minute${minutes > 1 ? 's' : ''}.`, + retryAfter, ); } if (signupCheck.action === 'resend') { // User exists but unconfirmed, rate limit passed - resend verification - const supabaseClient = createSupabaseServerInstance({ cookies, headers: request.headers }); + const supabaseClient = createServerClient({ cookies, headers: request.headers }); const { error: resendError } = await supabaseClient.auth.resend({ type: 'signup', email: email.toLowerCase(), options: { - emailRedirectTo: `${new URL(request.url).origin}/auth/verify`, + emailRedirectTo: `${getOrigin(request)}/auth/verify`, }, }); @@ -182,7 +151,7 @@ export const POST: APIRoute = async ({ request, cookies }) => { p_email: email.toLowerCase(), p_type: 'resend_verification', p_ip_address: requestorIp, - p_user_agent: request.headers.get('user-agent'), + p_user_agent: getUserAgent(request), p_request_path: '/api/auth/signup', p_user_id: signupCheck.user_id, p_status: resendError ? 'failed' : 'sent', @@ -205,12 +174,9 @@ export const POST: APIRoute = async ({ request, cookies }) => { } // Return success response (same as new signup) to trigger success UI - return new Response( - JSON.stringify({ - user: { id: signupCheck.user_id, email: signupCheck.email }, - }), - { status: 200 }, - ); + return successResponse({ + user: { id: signupCheck.user_id, email: signupCheck.email }, + }); } // signupCheck.action === 'create' - proceed with new user creation @@ -219,16 +185,16 @@ export const POST: APIRoute = async ({ request, cookies }) => { email, password, options: { - emailRedirectTo: `${new URL(request.url).origin}/auth/verify`, + emailRedirectTo: `${getOrigin(request)}/auth/verify`, }, }); if (authError) { - return new Response(JSON.stringify({ error: authError.message }), { status: 400 }); + return errorResponse(authError.message, 400); } if (!authData.user) { - return new Response(JSON.stringify({ error: 'User creation failed' }), { status: 500 }); + return errorResponse('User creation failed', 500); } // FIX 6: Log email send for new signup (non-blocking) @@ -237,7 +203,7 @@ export const POST: APIRoute = async ({ request, cookies }) => { p_email: email.toLowerCase(), p_type: 'signup_verification', p_ip_address: requestorIp, - p_user_agent: request.headers.get('user-agent'), + p_user_agent: getUserAgent(request), p_request_path: '/api/auth/signup', p_user_id: authData.user.id, }); @@ -264,17 +230,14 @@ export const POST: APIRoute = async ({ request, cookies }) => { }); if (redemptionResult.success) { - return new Response( - JSON.stringify({ - user: authData.user, - organization: { - id: redemptionResult.organizationId, - slug: redemptionResult.organizationSlug, - name: redemptionResult.organizationName, - }, - }), - { status: 200 }, - ); + return successResponse({ + user: authData.user, + organization: { + id: redemptionResult.organizationId, + slug: redemptionResult.organizationSlug, + name: redemptionResult.organizationName, + }, + }); } else { // Log invite redemption failure but don't block signup console.error('Failed to redeem invite during signup:', redemptionResult.error); @@ -292,23 +255,6 @@ export const POST: APIRoute = async ({ request, cookies }) => { console.error('Status update exception (function may not exist yet):', statusException); } - return new Response(JSON.stringify({ user: authData.user }), { status: 200 }); - } catch (err) { - console.error('Signup error:', err); - - // Mark signup as failed (FIX 3) - use stored email variable - if (userEmail) { - try { - const supabase = createSupabaseAdminInstance({ cookies, headers: request.headers }); - await supabase.rpc('update_signup_status', { - p_email: userEmail.toLowerCase(), - p_status: 'failed', - }); - } catch (updateErr) { - console.error('Failed to update signup status on error:', updateErr); - } - } - - return new Response(JSON.stringify({ error: 'An unexpected error occurred' }), { status: 500 }); - } -}; + return successResponse({ user: authData.user }); + }), +); diff --git a/src/pages/api/auth/update-password.ts b/src/pages/api/auth/update-password.ts index 69b3fb7..4f90f52 100644 --- a/src/pages/api/auth/update-password.ts +++ b/src/pages/api/auth/update-password.ts @@ -1,101 +1,72 @@ import type { APIRoute } from 'astro'; -import { createSupabaseServerInstance } from '@/db/supabase.client'; -import { verifyCaptcha } from '@/services/captcha'; -import { CF_CAPTCHA_SECRET_KEY } from 'astro:env/server'; +import { withCaptcha } from '../guards/withCaptcha'; +import { + successResponse, + errorResponse, + validationError, + unauthorizedError, +} from '../utils/apiResponse'; +import { createServerClient } from '../utils/supabaseHelpers'; /** * Update password endpoint. * NOTE: The recovery token must be verified FIRST via /api/auth/verify-token * to establish a session. This endpoint then uses that session to update the password. */ -export const POST: APIRoute = async ({ request, cookies }) => { - try { - const { password, confirmPassword, captchaToken } = (await request.json()) as { - password: string; - confirmPassword: string; - captchaToken: string; - }; +export const POST: APIRoute = withCaptcha<{ + password: string; + confirmPassword: string; + captchaToken: string; +}>(async ({ body, request, cookies }) => { + const { password, confirmPassword } = body; - // Validate inputs - if (!password || !confirmPassword || password !== confirmPassword || !captchaToken) { - return new Response( - JSON.stringify({ - error: 'Password, confirm password, and captcha token are required and must match', - }), - { - status: 400, - }, - ); - } - - // Verify captcha on backend - const requestorIp = request.headers.get('cf-connecting-ip') || ''; - const captchaResult = await verifyCaptcha(CF_CAPTCHA_SECRET_KEY, captchaToken, requestorIp); + // Validate inputs + if (!password || !confirmPassword) { + return validationError('Password and confirm password are required'); + } - if (!captchaResult.success) { - return new Response( - JSON.stringify({ - error: 'Security verification failed. Please try again.', - errorCodes: captchaResult['error-codes'], - }), - { status: 400 }, - ); - } + if (password !== confirmPassword) { + return validationError('Passwords do not match'); + } - // Create server instance for user authentication operations - const supabase = createSupabaseServerInstance({ cookies, headers: request.headers }); + // Create server instance for user authentication operations + const supabase = createServerClient({ cookies, headers: request.headers }); - console.log('[UPDATE-PASSWORD] Starting password update'); + console.log('[UPDATE-PASSWORD] Starting password update'); - // Check that user has an established session (from verify-token endpoint) - const { data: sessionData } = await supabase.auth.getSession(); - console.log('[UPDATE-PASSWORD] Current session:', { - hasSession: !!sessionData.session, - userId: sessionData.session?.user?.id, - }); + // Check that user has an established session (from verify-token endpoint) + const { data: sessionData } = await supabase.auth.getSession(); + console.log('[UPDATE-PASSWORD] Current session:', { + hasSession: !!sessionData.session, + userId: sessionData.session?.user?.id, + }); - if (!sessionData.session) { - console.error('[UPDATE-PASSWORD] No active session found'); - return new Response( - JSON.stringify({ error: 'No active session. Please verify your reset token first.' }), - { - status: 401, - }, - ); - } + if (!sessionData.session) { + console.error('[UPDATE-PASSWORD] No active session found'); + return unauthorizedError('No active session. Please verify your reset token first.'); + } - // Update password using the established user session - const updateResult = await supabase.auth.updateUser({ - password, - }); + // Update password using the established user session + const updateResult = await supabase.auth.updateUser({ + password, + }); - console.log('[UPDATE-PASSWORD] updateUser result:', { - hasError: !!updateResult.error, - errorMessage: updateResult.error?.message, - hasUser: !!updateResult.data?.user, - userId: updateResult.data?.user?.id, - }); + console.log('[UPDATE-PASSWORD] updateUser result:', { + hasError: !!updateResult.error, + errorMessage: updateResult.error?.message, + hasUser: !!updateResult.data?.user, + userId: updateResult.data?.user?.id, + }); - if (updateResult.error) { - console.error('[UPDATE-PASSWORD] Password update failed:', updateResult.error.message); - return new Response(JSON.stringify({ error: updateResult.error.message }), { - status: 400, - }); - } + if (updateResult.error) { + console.error('[UPDATE-PASSWORD] Password update failed:', updateResult.error.message); + return errorResponse(updateResult.error.message, 400); + } - console.log( - '[UPDATE-PASSWORD] Password updated successfully for user:', - updateResult.data?.user?.email, - ); + console.log( + '[UPDATE-PASSWORD] Password updated successfully for user:', + updateResult.data?.user?.email, + ); - return new Response(JSON.stringify({ message: 'Password updated successfully' }), { - status: 200, - }); - } catch (err) { - console.error('Update password endpoint error:', err instanceof Error ? err.message : err); - if (err instanceof SyntaxError) { - return new Response(JSON.stringify({ error: 'Invalid request body' }), { status: 400 }); - } - return new Response(JSON.stringify({ error: 'An unexpected error occurred' }), { status: 500 }); - } -}; + return successResponse({ message: 'Password updated successfully' }); +}); diff --git a/src/pages/api/guards/withAuth.ts b/src/pages/api/guards/withAuth.ts new file mode 100644 index 0000000..756ecf7 --- /dev/null +++ b/src/pages/api/guards/withAuth.ts @@ -0,0 +1,67 @@ +/** + * Guard to check if a user is authenticated before executing the handler + */ + +import type { APIRoute, APIContext } from 'astro'; +import { unauthorizedError } from '../utils/apiResponse'; + +/** + * Wrap an API route handler with authentication checking + * + * This middleware checks if `locals.user` exists (populated by Astro middleware) + * and returns a 401 Unauthorized response if the user is not authenticated. + * + * @param handler - The API route handler to execute if the user is authenticated + * @param message - Optional custom error message when not authenticated + * + * @example + * export const GET: APIRoute = withAuth(async ({ locals }) => { + * // locals.user is guaranteed to exist here + * const userId = locals.user.id; + * return successResponse({ userId }); + * }); + */ +export function withAuth(handler: APIRoute, message?: string): APIRoute { + return async (context: APIContext) => { + if (!context.locals.user) { + return unauthorizedError(message || 'Unauthorized'); + } + + return handler(context); + }; +} + +/** + * Wrap an API route handler with admin role checking + * + * This middleware checks if the user has admin role in the active organization + * (requires locals.promptLibrary.activeOrganization to be populated) + * + * @param handler - The API route handler to execute if the user is an admin + * @param message - Optional custom error message when not authorized + * + * @example + * export const POST: APIRoute = withAdminRole(async ({ locals }) => { + * // User is guaranteed to be an admin here + * return successResponse({ message: 'Admin operation successful' }); + * }); + */ +export function withAdminRole(handler: APIRoute, message?: string): APIRoute { + return async (context: APIContext) => { + // First check if user is authenticated + if (!context.locals.user) { + return unauthorizedError('Unauthorized'); + } + + // Check if active organization exists and user has admin role + if (!context.locals.promptLibrary?.activeOrganization) { + return unauthorizedError(message || 'No active organization'); + } + + if (context.locals.promptLibrary.activeOrganization.role !== 'admin') { + return unauthorizedError(message || 'Admin role required'); + } + + return handler(context); + }; +} diff --git a/src/pages/api/guards/withCaptcha.ts b/src/pages/api/guards/withCaptcha.ts new file mode 100644 index 0000000..896b334 --- /dev/null +++ b/src/pages/api/guards/withCaptcha.ts @@ -0,0 +1,113 @@ +/** + * Guard to verify Cloudflare Turnstile captcha before executing the handler + */ + +import type { APIRoute, APIContext } from 'astro'; +import { verifyCaptcha } from '@/services/captcha'; +import { CF_CAPTCHA_SECRET_KEY } from 'astro:env/server'; +import { errorResponse, validationError } from '../utils/apiResponse'; +import { getClientIp } from '../utils/supabaseHelpers'; + +/** + * Extended API context with parsed request body + */ +export type CaptchaContext> = APIContext & { + /** + * The parsed request body with captchaToken removed + */ + body: Omit; + /** + * The verified captcha token + */ + captchaToken: string; +}; + +/** + * API route handler with captcha verification + */ +export type CaptchaAPIRoute> = ( + context: CaptchaContext, +) => Response | Promise; + +/** + * Wrap an API route handler with captcha verification + * + * This middleware: + * 1. Parses the request body + * 2. Extracts and verifies the captchaToken + * 3. Passes the parsed body (without captchaToken) to the handler + * 4. Returns an error if captcha verification fails + * + * @param handler - The API route handler to execute after captcha verification + * + * @example + * export const POST: APIRoute = withCaptcha<{ email: string; password: string }>( + * async ({ body, captchaToken, cookies }) => { + * // body.captchaToken is not present - it's been verified and removed + * // The captchaToken is available as a separate parameter + * const { email, password } = body; + * return successResponse({ message: 'Success' }); + * } + * ); + */ +export function withCaptcha( + handler: CaptchaAPIRoute, +): APIRoute { + return async (context: APIContext) => { + try { + // Parse request body + const body = (await context.request.json()) as T; + + // Check if captchaToken is present + if (!body.captchaToken) { + return validationError('Captcha token is required'); + } + + // Verify captcha + const requestorIp = getClientIp(context.request); + const captchaResult = await verifyCaptcha( + CF_CAPTCHA_SECRET_KEY, + body.captchaToken, + requestorIp, + ); + + if (!captchaResult.success) { + return errorResponse('Security verification failed. Please try again.', 400, { + type: 'captcha_failed', + errorCodes: captchaResult['error-codes'], + }); + } + + // Extract captchaToken and pass the rest to handler + const { captchaToken, ...restBody } = body; + + // Create extended context + const captchaContext: CaptchaContext = { + ...context, + body: restBody as Omit, + captchaToken, + }; + + return handler(captchaContext); + } catch (err) { + // Handle JSON parsing errors + if (err instanceof SyntaxError) { + return validationError('Invalid request body'); + } + throw err; + } + }; +} + +/** + * Compose withCaptcha with other middleware + * Useful for combining feature flags with captcha verification + * + * @example + * export const POST: APIRoute = withFeatureFlag('auth', + * withCaptcha(async ({ body }) => { + * return successResponse({ message: 'Success' }); + * }) + * ); + */ +export { withCaptcha as default }; diff --git a/src/pages/api/guards/withFeatureFlag.ts b/src/pages/api/guards/withFeatureFlag.ts new file mode 100644 index 0000000..f4d34a4 --- /dev/null +++ b/src/pages/api/guards/withFeatureFlag.ts @@ -0,0 +1,32 @@ +/** + * Guard to check if a feature flag is enabled before executing the handler + */ + +import type { APIRoute, APIContext } from 'astro'; +import { isFeatureEnabled, type FeatureFlag } from '@/features/featureFlags'; +import { forbiddenError } from '../utils/apiResponse'; + +/** + * Wrap an API route handler with feature flag checking + * + * @param flag - The feature flag to check + * @param handler - The API route handler to execute if the flag is enabled + * @param message - Optional custom error message when the feature is disabled + * + * @example + * export const POST: APIRoute = withFeatureFlag('auth', async ({ request }) => { + * // This code only runs if the 'auth' feature is enabled + * return successResponse({ message: 'Success' }); + * }); + */ +export function withFeatureFlag(flag: FeatureFlag, handler: APIRoute, message?: string): APIRoute { + return async (context: APIContext) => { + if (!isFeatureEnabled(flag)) { + const errorMessage = + message || `${flag.charAt(0).toUpperCase() + flag.slice(1)} is currently disabled`; + return forbiddenError(errorMessage); + } + + return handler(context); + }; +} diff --git a/src/pages/api/utils/apiResponse.ts b/src/pages/api/utils/apiResponse.ts new file mode 100644 index 0000000..207e43d --- /dev/null +++ b/src/pages/api/utils/apiResponse.ts @@ -0,0 +1,152 @@ +/** + * Standardized API response helpers for consistent response formatting + * across all API routes. + */ + +export type ApiSuccessResponse = { + data?: T; + message?: string; + [key: string]: unknown; +}; + +export type ApiErrorResponse = { + error: string; + type?: string; + errorCodes?: string[]; + retryAfter?: number; + developerMessage?: string; + [key: string]: unknown; +}; + +/** + * Create a successful JSON response + * @param data - The data to return + * @param status - HTTP status code (default: 200) + */ +export function successResponse(data: T, status: number = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { + 'Content-Type': 'application/json', + }, + }); +} + +/** + * Create an error JSON response + * @param error - Error message + * @param status - HTTP status code (default: 400) + * @param options - Additional error details + */ +export function errorResponse( + error: string, + status: number = 400, + options?: { + type?: string; + errorCodes?: string[]; + retryAfter?: number; + developerMessage?: string; + [key: string]: unknown; + }, +): Response { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + // Add Retry-After header if provided + if (options?.retryAfter) { + headers['Retry-After'] = options.retryAfter.toString(); + } + + return new Response( + JSON.stringify({ + error, + ...options, + } as ApiErrorResponse), + { status, headers }, + ); +} + +/** + * Create a validation error response + * @param message - Validation error message + * @param fields - Optional field-specific errors + */ +export function validationError(message: string, fields?: Record): Response { + return errorResponse(message, 400, { + type: 'validation_error', + fields, + }); +} + +/** + * Create an unauthorized error response + * @param message - Error message (default: 'Unauthorized') + */ +export function unauthorizedError(message: string = 'Unauthorized'): Response { + return errorResponse(message, 401, { + type: 'unauthorized', + }); +} + +/** + * Create a forbidden error response + * @param message - Error message (default: 'Forbidden') + */ +export function forbiddenError(message: string = 'Forbidden'): Response { + return errorResponse(message, 403, { + type: 'forbidden', + }); +} + +/** + * Create a not found error response + * @param message - Error message (default: 'Not found') + */ +export function notFoundError(message: string = 'Not found'): Response { + return errorResponse(message, 404, { + type: 'not_found', + }); +} + +/** + * Create a rate limit error response + * @param message - Rate limit error message + * @param retryAfter - Seconds until the user can retry + */ +export function rateLimitError(message: string, retryAfter: number): Response { + return errorResponse(message, 429, { + type: 'rate_limit', + retryAfter, + }); +} + +/** + * Create an internal server error response + * @param message - Error message (default: 'An unexpected error occurred') + * @param developerMessage - Optional detailed error for development + */ +export function serverError( + message: string = 'An unexpected error occurred', + developerMessage?: string, +): Response { + return errorResponse(message, 500, { + type: 'server_error', + developerMessage: import.meta.env.DEV ? developerMessage : undefined, + }); +} + +/** + * Create a service unavailable error response + * @param message - Error message + * @param retryAfter - Optional seconds until service is expected to be available + */ +export function serviceUnavailableError( + message: string = 'Service temporarily unavailable', + retryAfter?: number, +): Response { + return errorResponse(message, 503, { + type: 'service_unavailable', + retryAfter, + }); +} diff --git a/src/pages/api/utils/supabaseHelpers.ts b/src/pages/api/utils/supabaseHelpers.ts new file mode 100644 index 0000000..2a72c97 --- /dev/null +++ b/src/pages/api/utils/supabaseHelpers.ts @@ -0,0 +1,59 @@ +/** + * Supabase client utilities for API routes + */ + +import type { AstroCookies } from 'astro'; +import { createSupabaseServerInstance, createSupabaseAdminInstance } from '@/db/supabase.client'; + +/** + * Context required to create a Supabase client + */ +export type SupabaseContext = { + cookies: AstroCookies; + headers: Headers; +}; + +/** + * Create a Supabase server instance (user-scoped) + * Use this for operations that should respect RLS policies + */ +export function createServerClient(context: SupabaseContext) { + return createSupabaseServerInstance({ + cookies: context.cookies, + headers: context.headers, + }); +} + +/** + * Create a Supabase admin instance (bypasses RLS) + * Use this for operations that need to bypass RLS policies + * (e.g., signup, admin operations) + */ +export function createAdminClient(context: SupabaseContext) { + return createSupabaseAdminInstance({ + cookies: context.cookies, + headers: context.headers, + }); +} + +/** + * Extract the client IP address from request headers + * Checks Cloudflare's cf-connecting-ip header first + */ +export function getClientIp(request: Request): string { + return request.headers.get('cf-connecting-ip') || ''; +} + +/** + * Extract the user agent from request headers + */ +export function getUserAgent(request: Request): string | null { + return request.headers.get('user-agent'); +} + +/** + * Extract the origin from a request URL + */ +export function getOrigin(request: Request): string { + return new URL(request.url).origin; +}