@@ -215,70 +215,63 @@ export default async function proxy(request: NextRequest) {
215215 normalizedPath === route || normalizedPath . startsWith ( route + '/' )
216216 )
217217
218- // Update session — validates token server-side (1 auth API call) and returns the user.
219- // Re-using the user here avoids a second getUser() network call.
220- const { response : supabaseResponse , user } = await updateSession ( request )
221-
222- // Set tenant + user ID headers on the response for downstream server components.
223- // This lets server components read the user ID from headers instead of calling
224- // getUser() again (which is a network call to Supabase Auth on every invocation).
218+ // --- Public routes: skip auth entirely when no cookies ---
225219 intlResponse . headers . set ( 'x-tenant-id' , tenantId )
220+ const hasAuthCookies = request . cookies . getAll ( ) . some ( c => c . name . startsWith ( 'sb-' ) )
221+
222+ if ( isPublicRoute && ! hasAuthCookies ) {
223+ // Fast path: unauthenticated user on public page — zero auth API calls
224+ return intlResponse
225+ }
226+
227+ // --- Auth session validation (1 auth API call via getUser()) ---
228+ // Only runs when auth cookies exist (skip for bots, crawlers, unauthenticated visitors)
229+ const { response : supabaseResponse , user } = await updateSession ( request )
226230 supabaseResponse . headers . set ( 'x-tenant-id' , tenantId )
231+
232+ // Set user ID header so server components can read it without calling getUser() again
227233 if ( user ) {
228234 request . headers . set ( 'x-user-id' , user . id )
229235 intlResponse . headers . set ( 'x-user-id' , user . id )
230236 supabaseResponse . headers . set ( 'x-user-id' , user . id )
231237 }
232238
233- // Single Supabase client used for the rest of this middleware run.
234- // getSession() reads from cookie (no network call) — safe because updateSession
235- // already validated the token server-side above.
236- const supabase = createServerClient (
237- process . env . NEXT_PUBLIC_SUPABASE_URL ! ,
238- process . env . NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY ! ,
239- {
240- cookies : {
241- getAll ( ) { return request . cookies . getAll ( ) } ,
242- setAll ( cookiesToSet ) {
243- // Propagate any cookie writes (e.g. from refreshSession) to the response
244- const cookieDomain = ( ( ) => {
245- const d = process . env . NEXT_PUBLIC_PLATFORM_DOMAIN ?. split ( ':' ) [ 0 ]
246- if ( ! d || d === 'localhost' || d === '127.0.0.1' ) return undefined
247- return `.${ d } `
248- } ) ( )
249- cookiesToSet . forEach ( ( { name, value } ) => request . cookies . set ( name , value ) )
250- cookiesToSet . forEach ( ( { name, value, options } ) =>
251- supabaseResponse . cookies . set ( name , value , {
252- ...options ,
253- ...( cookieDomain ? { domain : cookieDomain } : { } ) ,
254- } )
255- )
256- } ,
257- } ,
258- }
259- )
260-
261- let session = null
239+ // Read JWT claims from cookie (no network call) — getSession() is a local read
240+ let userRole : 'student' | 'teacher' | 'admin' = 'student'
262241 if ( user ) {
263242 try {
264- const { data : sessionData } = await supabase . auth . getSession ( )
265- session = sessionData . session
243+ // Parse JWT directly from cookie to avoid creating another Supabase client
244+ const authCookie = request . cookies . getAll ( ) . find ( c => c . name . startsWith ( 'sb-' ) && c . name . endsWith ( '-auth-token' ) )
245+ if ( authCookie ) {
246+ const sessionData = JSON . parse ( authCookie . value )
247+ const accessToken = sessionData ?. access_token || sessionData ?. [ 0 ] ?. access_token
248+ if ( accessToken ) {
249+ const payload = JSON . parse ( atob ( accessToken . split ( '.' ) [ 1 ] ) )
250+ userRole = payload . tenant_role || payload . user_role || 'student'
251+ }
252+ }
266253 } catch {
267- session = null
268- }
269- }
270-
271- let userRole : 'student' | 'teacher' | 'admin' = 'student'
272- if ( session ?. access_token ) {
273- try {
274- const payload = JSON . parse ( atob ( session . access_token . split ( '.' ) [ 1 ] ) )
275- userRole = payload . tenant_role || payload . user_role || 'student'
276- } catch ( e ) {
277- // ignore
254+ // Fallback: try chunked cookies (sb-*-auth-token.0, .1, etc.)
255+ try {
256+ const chunks = request . cookies . getAll ( )
257+ . filter ( c => c . name . match ( / ^ s b - .* - a u t h - t o k e n \. \d + $ / ) )
258+ . sort ( ( a , b ) => a . name . localeCompare ( b . name ) )
259+ if ( chunks . length > 0 ) {
260+ const combined = chunks . map ( c => c . value ) . join ( '' )
261+ const sessionData = JSON . parse ( combined )
262+ const accessToken = sessionData ?. access_token
263+ if ( accessToken ) {
264+ const payload = JSON . parse ( atob ( accessToken . split ( '.' ) [ 1 ] ) )
265+ userRole = payload . tenant_role || payload . user_role || 'student'
266+ }
267+ }
268+ } catch {
269+ // ignore — default to 'student'
270+ }
278271 }
279272 }
280273
281- // Auth Guards
274+ // Auth Guards — public routes
282275 if ( isPublicRoute ) {
283276 if ( user && ( normalizedPath . startsWith ( '/auth/login' ) || normalizedPath . startsWith ( '/auth/sign-up' ) ) ) {
284277 const dashboardUrl = new URL ( `/${ locale } /dashboard/${ userRole } ` , request . url )
@@ -299,6 +292,31 @@ export default async function proxy(request: NextRequest) {
299292 return NextResponse . redirect ( redirectUrl )
300293 }
301294
295+ // Supabase client for DB queries (tenant_users check) — no auth API calls
296+ const supabase = createServerClient (
297+ process . env . NEXT_PUBLIC_SUPABASE_URL ! ,
298+ process . env . NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY ! ,
299+ {
300+ cookies : {
301+ getAll ( ) { return request . cookies . getAll ( ) } ,
302+ setAll ( cookiesToSet ) {
303+ const cookieDomain = ( ( ) => {
304+ const d = process . env . NEXT_PUBLIC_PLATFORM_DOMAIN ?. split ( ':' ) [ 0 ]
305+ if ( ! d || d === 'localhost' || d === '127.0.0.1' ) return undefined
306+ return `.${ d } `
307+ } ) ( )
308+ cookiesToSet . forEach ( ( { name, value } ) => request . cookies . set ( name , value ) )
309+ cookiesToSet . forEach ( ( { name, value, options } ) =>
310+ supabaseResponse . cookies . set ( name , value , {
311+ ...options ,
312+ ...( cookieDomain ? { domain : cookieDomain } : { } ) ,
313+ } )
314+ )
315+ } ,
316+ } ,
317+ }
318+ )
319+
302320 // Check if user is a member of the current tenant and get their tenant role
303321 if ( ! normalizedPath . startsWith ( '/join-school' ) ) {
304322 const { data : membership } = await supabase
@@ -310,7 +328,6 @@ export default async function proxy(request: NextRequest) {
310328 . single ( )
311329
312330 if ( ! membership ) {
313- // User is not a member of this tenant - redirect to join page
314331 const joinUrl = new URL ( `/${ locale } /join-school` , request . url )
315332 return NextResponse . redirect ( joinUrl )
316333 }
@@ -321,30 +338,41 @@ export default async function proxy(request: NextRequest) {
321338 }
322339
323340 // Sync app_metadata.tenant_id if it doesn't match the current subdomain tenant.
324- // This ensures get_tenant_id() in RLS policies returns the correct tenant.
325- const jwtTenantId = session ?. access_token
326- ? ( ( ) => { try { return JSON . parse ( atob ( session . access_token . split ( '.' ) [ 1 ] ) ) . tenant_id } catch { return null } } ) ( )
327- : null
328-
329- if ( jwtTenantId !== tenantId ) {
330- // Update user's app_metadata so the next JWT refresh includes the correct tenant_id
331- try {
332- await fetch ( `${ SUPABASE_URL } /auth/v1/admin/users/${ user . id } ` , {
341+ // Only update app_metadata via admin API (no refreshSession — that burns rate limits).
342+ // The JWT will pick up the new tenant_id on the next natural token refresh.
343+ try {
344+ const authCookie = request . cookies . getAll ( ) . find ( c => c . name . startsWith ( 'sb-' ) && c . name . endsWith ( '-auth-token' ) )
345+ const chunks = request . cookies . getAll ( )
346+ . filter ( c => c . name . match ( / ^ s b - .* - a u t h - t o k e n \. \d + $ / ) )
347+ . sort ( ( a , b ) => a . name . localeCompare ( b . name ) )
348+ let accessToken : string | null = null
349+ if ( authCookie ) {
350+ const sd = JSON . parse ( authCookie . value )
351+ accessToken = sd ?. access_token || sd ?. [ 0 ] ?. access_token
352+ } else if ( chunks . length > 0 ) {
353+ const sd = JSON . parse ( chunks . map ( c => c . value ) . join ( '' ) )
354+ accessToken = sd ?. access_token
355+ }
356+ const jwtTenantId = accessToken
357+ ? JSON . parse ( atob ( accessToken . split ( '.' ) [ 1 ] ) ) . tenant_id
358+ : null
359+
360+ if ( jwtTenantId !== tenantId ) {
361+ // Fire-and-forget: update app_metadata so the NEXT token refresh gets correct tenant_id.
362+ // Do NOT call refreshSession() — it makes another auth API call and if rate-limited
363+ // it creates an infinite retry loop on every request.
364+ fetch ( `${ SUPABASE_URL } /auth/v1/admin/users/${ user . id } ` , {
333365 method : 'PUT' ,
334366 headers : {
335367 apikey : SUPABASE_SERVICE_ROLE_KEY ,
336368 Authorization : `Bearer ${ SUPABASE_SERVICE_ROLE_KEY } ` ,
337369 'Content-Type' : 'application/json' ,
338370 } ,
339- body : JSON . stringify ( {
340- app_metadata : { tenant_id : tenantId } ,
341- } ) ,
342- } )
343- // Refresh the session so the JWT gets updated claims immediately
344- await supabase . auth . refreshSession ( )
345- } catch {
346- // Non-blocking — next request will retry
371+ body : JSON . stringify ( { app_metadata : { tenant_id : tenantId } } ) ,
372+ } ) . catch ( ( ) => { } ) // Non-blocking
347373 }
374+ } catch {
375+ // JWT parsing failed — skip tenant sync
348376 }
349377 }
350378
0 commit comments