Created
October 13, 2025 03:46
-
-
Save swdevbali/67040bbef06a91c691ea149020f40423 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { NextResponse } from 'next/server' | |
| import type { NextRequest } from 'next/server' | |
| import { createServerClient as createSupabaseServerClient } from '@supabase/ssr' | |
| // Rate limiting map (in production, use Redis or similar) | |
| const rateLimitMap = new Map<string, { count: number; resetTime: number }>() | |
| export async function middleware(request: NextRequest) { | |
| // Rate limiting | |
| const clientId = request.ip || request.headers.get('x-forwarded-for') || 'unknown' | |
| const now = Date.now() | |
| const windowMs = 60000 // 1 minute | |
| const maxRequests = 100 | |
| if (rateLimitMap.has(clientId)) { | |
| const { count, resetTime } = rateLimitMap.get(clientId)! | |
| if (now < resetTime) { | |
| if (count >= maxRequests) { | |
| return new Response('Too Many Requests', { status: 429 }) | |
| } | |
| rateLimitMap.set(clientId, { count: count + 1, resetTime }) | |
| } else { | |
| rateLimitMap.set(clientId, { count: 1, resetTime: now + windowMs }) | |
| } | |
| } else { | |
| rateLimitMap.set(clientId, { count: 1, resetTime: now + windowMs }) | |
| } | |
| // Validate request path for security | |
| const path = request.nextUrl.pathname | |
| if (path.includes('..') || path.includes('//') || path.includes('\\')) { | |
| return new Response('Invalid path', { status: 400 }) | |
| } | |
| // Validate environment variables | |
| const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL | |
| const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY | |
| if (!supabaseUrl || !serviceRoleKey) { | |
| console.error('[Middleware] Missing required environment variables') | |
| return new Response('Service unavailable', { status: 503 }) | |
| } | |
| let response = NextResponse.next({ | |
| request: { | |
| headers: request.headers, | |
| }, | |
| }) | |
| // Add security headers | |
| response.headers.set('X-Content-Type-Options', 'nosniff') | |
| response.headers.set('X-Frame-Options', 'DENY') | |
| response.headers.set('X-XSS-Protection', '1; mode=block') | |
| response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin') | |
| response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()') | |
| // Use service role key to validate sessions | |
| const supabase = createSupabaseServerClient( | |
| supabaseUrl, | |
| serviceRoleKey, | |
| { | |
| cookies: { | |
| get(name: string) { | |
| return request.cookies.get(name)?.value | |
| }, | |
| set(name: string, value: string, options: any) { | |
| request.cookies.set({ | |
| name, | |
| value, | |
| ...options, | |
| }) | |
| response = NextResponse.next({ | |
| request: { | |
| headers: request.headers, | |
| }, | |
| }) | |
| response.cookies.set({ | |
| name, | |
| value, | |
| ...options, | |
| }) | |
| }, | |
| remove(name: string, options: any) { | |
| request.cookies.set({ | |
| name, | |
| value: '', | |
| ...options, | |
| }) | |
| response = NextResponse.next({ | |
| request: { | |
| headers: request.headers, | |
| }, | |
| }) | |
| response.cookies.set({ | |
| name, | |
| value: '', | |
| ...options, | |
| }) | |
| }, | |
| }, | |
| } | |
| ) | |
| // Get the authenticated user (verifies with Supabase Auth server) | |
| const { data: { user } } = await supabase.auth.getUser() | |
| const isAuthenticated = !!user | |
| const validatedUser = user || null | |
| // Secure audit logging (no sensitive data) | |
| console.log('[AUDIT]', JSON.stringify({ | |
| timestamp: new Date().toISOString(), | |
| path: request.nextUrl.pathname, | |
| method: request.method, | |
| isAuthenticated, | |
| userId: validatedUser?.id ? '***masked***' : 'anonymous', | |
| ip: request.ip || 'unknown' | |
| })) | |
| // Public routes that don't require auth | |
| const publicRoutes = ['/', '/login', '/signup', '/forgot-password', '/reset-password', '/auth/callback'] | |
| const isPublicRoute = publicRoutes.includes(request.nextUrl.pathname) || | |
| request.nextUrl.pathname.startsWith('/invite/') | |
| // Special routes that require specific permissions | |
| const isSuperAdminRoute = request.nextUrl.pathname === '/create-new-tenant' || | |
| request.nextUrl.pathname.startsWith('/dashboard/control-center') | |
| // If not authenticated and trying to access protected route | |
| if (!isAuthenticated && !isPublicRoute) { | |
| console.log('[Middleware] Not authenticated, redirecting to /') | |
| return NextResponse.redirect(new URL('/', request.url)) | |
| } | |
| // If authenticated and trying to access public routes (except root) | |
| if (isAuthenticated && request.nextUrl.pathname === '/') { | |
| // Don't redirect from root, let the page handle it | |
| return response | |
| } | |
| // Special handling for dashboard routes | |
| if (isAuthenticated && request.nextUrl.pathname.startsWith('/dashboard')) { | |
| console.log('[Middleware] Dashboard route access for authenticated user') | |
| // First check if user is superadmin | |
| const superAdminUrl = new URL('/api/users/is-superadmin', request.url) | |
| try { | |
| const headers = new Headers() | |
| // Rely on cookies for authentication; do not forward unverified tokens | |
| headers.set('Cookie', request.headers.get('Cookie') || '') | |
| const superAdminResponse = await fetch(superAdminUrl, { | |
| headers, | |
| method: 'GET', | |
| }) | |
| if (superAdminResponse.ok) { | |
| const superAdminData = await superAdminResponse.json() | |
| console.log('[Middleware] Superadmin check result:', { | |
| isSuperAdmin: superAdminData.isSuperAdmin | |
| }) | |
| // Superadmins manage the platform. If there are no tenants yet, guide them to /tenants | |
| if (superAdminData.isSuperAdmin) { | |
| try { | |
| const allOrgsUrl = new URL('/api/organizations/all', request.url) | |
| const allHeaders = new Headers() | |
| allHeaders.set('Cookie', request.headers.get('Cookie') || '') | |
| const allOrgsResponse = await fetch(allOrgsUrl, { | |
| headers: allHeaders, | |
| method: 'GET', | |
| }) | |
| if (allOrgsResponse.ok) { | |
| const allData = await allOrgsResponse.json() | |
| const orgs = Array.isArray(allData.organizations) ? allData.organizations : [] | |
| if (orgs.length === 0) { | |
| console.log('[Middleware] Superadmin with no tenants found → redirecting to /tenants') | |
| return NextResponse.redirect(new URL('/tenants', request.url)) | |
| } | |
| } | |
| } catch (e) { | |
| console.error('[Middleware] Error checking all organizations for superadmin') | |
| } | |
| console.log('[Middleware] ✅ Allowing superadmin access to dashboard') | |
| return response | |
| } | |
| } else { | |
| console.error('[Middleware] Superadmin check failed with status:', superAdminResponse.status) | |
| return NextResponse.redirect(new URL('/', request.url)) | |
| } | |
| } catch (error) { | |
| console.error('[Middleware] Error checking superadmin status:', error instanceof Error ? error.message : 'Unknown error') | |
| // Fail securely - deny access on error | |
| return NextResponse.redirect(new URL('/', request.url)) | |
| } | |
| // For non-superadmins, check if they have organizations | |
| const apiUrl = new URL('/api/organizations/check', request.url) | |
| try { | |
| const headers = new Headers() | |
| // Rely on cookies for authentication; do not forward unverified tokens | |
| headers.set('Cookie', request.headers.get('Cookie') || '') | |
| const checkResponse = await fetch(apiUrl, { | |
| headers, | |
| method: 'GET', | |
| }) | |
| if (checkResponse.ok) { | |
| const data = await checkResponse.json() | |
| console.log('[Middleware] Organization check result:', { | |
| hasOrganizations: data.hasOrganizations, | |
| isSuperAdmin: data.isSuperAdmin | |
| }) | |
| // If non-superadmin has no organizations, show waiting page | |
| // They cannot create tenants - only superadmin can | |
| if (!data.hasOrganizations) { | |
| console.log('[Middleware] ❌ User has no organizations, redirecting to login') | |
| // Redirect to a waiting page or show error | |
| // For now, just block access | |
| return NextResponse.redirect(new URL('/', request.url)) | |
| } | |
| } else { | |
| console.error('[Middleware] Organization check failed with status:', checkResponse.status) | |
| return NextResponse.redirect(new URL('/', request.url)) | |
| } | |
| } catch (error) { | |
| console.error('[Middleware] Error checking organizations:', error instanceof Error ? error.message : 'Unknown error') | |
| // Fail securely - deny access on error | |
| return NextResponse.redirect(new URL('/', request.url)) | |
| } | |
| } | |
| // For create-new-tenant page, ensure user is authenticated AND is superadmin | |
| if (isSuperAdminRoute) { | |
| if (!isAuthenticated) { | |
| console.log('[Middleware] Not authenticated for superadmin route, redirecting to /') | |
| return NextResponse.redirect(new URL('/', request.url)) | |
| } | |
| // Check if user is superadmin | |
| const superAdminUrl = new URL('/api/users/is-superadmin', request.url) | |
| try { | |
| const headers = new Headers() | |
| // Rely on cookies for authentication; do not forward unverified tokens | |
| headers.set('Cookie', request.headers.get('Cookie') || '') | |
| const superAdminResponse = await fetch(superAdminUrl, { | |
| headers, | |
| method: 'GET', | |
| }) | |
| if (superAdminResponse.ok) { | |
| const superAdminData = await superAdminResponse.json() | |
| // Only superadmins can create tenants | |
| if (!superAdminData.isSuperAdmin) { | |
| return NextResponse.redirect(new URL('/dashboard', request.url)) | |
| } | |
| } else { | |
| console.error('[Middleware] Superadmin check for tenant creation failed with status:', superAdminResponse.status) | |
| return NextResponse.redirect(new URL('/', request.url)) | |
| } | |
| } catch (error) { | |
| console.error('[Middleware] Error checking superadmin for tenant creation:', error instanceof Error ? error.message : 'Unknown error') | |
| // Fail securely - redirect to home instead of dashboard | |
| return NextResponse.redirect(new URL('/', request.url)) | |
| } | |
| } | |
| return response | |
| } | |
| // Configure which routes to run middleware on | |
| export const config = { | |
| matcher: [ | |
| /* | |
| * Match all request paths except for the ones starting with: | |
| * - api (API routes - handle auth in each route instead) | |
| * - _next/static (static files) | |
| * - _next/image (image optimization files) | |
| * - favicon.ico (favicon file) | |
| * - public folder | |
| */ | |
| '/((?!api|_next/static|_next/image|favicon.ico|public).*)', | |
| ], | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment