Skip to content

[Gap-Audit] ProtectedRoute auth-race: latent stale closure on user inside admin/layout.tsx async #51

@TortoiseWolfe

Description

@TortoiseWolfe

Summary

src/app/admin/layout.tsx:20-38 runs an async admin-check inside useEffect. It captures user via closure, then awaits an async service call, then calls router.push('/') based on the result. This is the same shape that was reverted three times before in ProtectedRoute (6b4c13a, 2c97e67, 259b38d).

The current implementation includes a cancelled flag that prevents the post-unmount redirect — that's a partial mitigation. The latent risk is that user may change mid-async (token refresh, sign-out, sign-in-as-different-user) and the closure still holds the stale reference, sending the wrong user to / or skipping the redirect entirely.

Code under review

useEffect(() => {
  if (authLoading) return;
  if (!user) return; // ProtectedRoute handles unauthenticated redirect
  let cancelled = false;
  (async () => {
    const service = new AdminAuthService(supabase);
    const admin = await service.checkIsAdmin(user.id);  // ← stale-closure risk
    if (cancelled) return;
    setIsAdmin(admin);
    if (!admin && !wasAdmin.current) router.push('/');  // ← acts on stale user
  })();
  return () => { cancelled = true; };
}, [user, authLoading, router]);

Repro paths (manual; need automated regression cover)

  1. Mount admin page as admin → token refresh fires → user ref is replaced → in-flight checkIsAdmin resolves against old user.idsetIsAdmin writes correct value (the value that goes with the OLD user). On unrelated re-renders this can also stash a wrong wasAdmin.current.
  2. Sign-out during the async call: cancelled prevents the redirect, but the "if (!user) return" guard ran before cancelled could fire, and setIsAdmin may still race the unmount.

Plan

  1. Add a regression test first. Mount <AdminLayout> with a controllable AuthContext, assert correct redirect under: (a) sign-out mid-async, (b) user-id change mid-async, (c) authLoading flipping back to true mid-async. Vitest + React Testing Library. Lives at tests/integration/auth/admin-layout.test.tsx.
  2. Pin the user reference for the duration of the effect: capture const targetUserId = user.id before the async, then assert at the resolution point that userRef.current === targetUserId before calling setIsAdmin or router.push. The userRef is a useRef synced via a separate effect.
  3. Centralize the pattern. This is the same shape as ProtectedRoute. Both should call into a shared useAuthGate({onAdmin, onNotAdmin}) hook so the next regression has one place to land. Live in src/hooks/useAuthGate.ts.
  4. Sweep the codebase for other useEffect blocks that await on a captured user and then router.push — likely candidates: any other layout.tsx files under src/app/, plus MessagingProvider, OnTheAccount page.

Acceptance

  • Regression test from step 1 passes
  • grep -rn 'router\.push.*user\.' src/app/ returns zero direct matches in async closures (use useAuthGate instead)
  • No new revert of this shape for 30 days

Reference

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinggap-auditIdentified during 2026-04-25 planned-vs-shipped auditpriority:p1High — fix soon (stability hotspot, low-hanging fruit, single-decision unlocks)

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions