"use client"; /** * Auth hooks. * * Startup hydration (S10 — the W1/W2/W3 rework): the access token lives in * JS memory only (never localStorage — security-architecture.md §AuthN), so * on mount the client re-issues it from the httpOnly `factory_refresh` * cookie via a single-flight POST /auth/refresh, then fetches the profile * with GET /auth/me (Bearer). The httpOnly cookie is the session source of * truth; the middleware only checks its existence for redirects. */ import { useCallback, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { authApi, isStaleRefreshError, type LoginInput, type RegisterInput } from "@/lib/api"; import { authStore, hasSession, useAuthStore } from "@/lib/auth-store"; import { useOutboxStore } from "@/stores/offlineQueue"; import { toast } from "@/stores/ui-store"; /** Current user (null while initializing or signed out). */ export function useAuth() { const user = useAuthStore((s) => s.user); const initializing = useAuthStore((s) => s.initializing); // Subscribe to the access token too: the bootstrap sets it WITHOUT setting // the user, and only this subscription re-renders the component so the // `enabled` below re-evaluates and fires /auth/me against the fresh token. const accessToken = useAuthStore((s) => s.accessToken); const hydrated = useRef(false); const me = useQuery({ queryKey: ["auth", "me"], queryFn: () => authApi.me(), // Runs once per tab when a session is hydrated (an access token is in // memory). While no token is held, the bootstrap below refreshes the // access token from the refresh cookie FIRST — /auth/me is Bearer-gated // (W3) and has no token in its body, so it can never issue one. enabled: !user && Boolean(accessToken), retry: false, staleTime: 5 * 60 * 1000, gcTime: Infinity }); // One-time silent refresh: cookie → new access token → (enables) /me. useEffect(() => { if (hydrated.current) return; hydrated.current = true; if (hasSession()) return; // already holding a token (e.g. right after login) let cancelled = false; (async () => { // `authApi.refresh` is the module-level single-flight primitive (api.ts // `refreshAccessToken`): a StrictMode double-mount — or two components // bootstrapping in the same document — share ONE in-flight // POST /auth/refresh, so the tab can never rotate its refresh cookie // twice in parallel. (The old bypass via mutatingApiFetch let two // concurrent bootstraps both rotate; the loser then replayed the // rotated-out cookie and tripped reuse detection → family kill.) let ok = false; try { await authApi.refresh(); ok = true; } catch (err) { if (isStaleRefreshError(err)) { // STALE RETRY (security-architecture.md §1.2): a concurrent // refresh — another tab, or a racing request — rotated the cookie // between our read and the server's. No session was killed, and // the browser jar already holds the rotated successor when that // rotation's response was observed. Per-tab single-flight cannot // dedupe ACROSS tabs, so retry exactly once: the second attempt // presents the live successor and rotates it forward. A second // stale answer (jar never advanced) or any other failure is // terminal → treat as no session. try { await authApi.refresh(); ok = true; } catch { ok = false; } } } if (!ok) { // No refresh cookie (or dead) → no session. End the initial spin so // the (app) layout redirects to /login instead of spinning forever. if (!cancelled) authStore.setInitializing(false); return; } if (cancelled) return; // The refresh already stored the access token; the store update // re-renders and re-evaluates `enabled` above, which fires /auth/me // against the fresh Bearer token. })(); return () => { cancelled = true; }; }, []); useEffect(() => { if (me.data) { // /auth/me carries no token — keep the one the refresh flow stored. authStore.setUser(me.data.user, authStore.accessToken); } if (me.error && !me.data) { // /me failed (a 401 is transparent — doFetch refreshes + retries once // before this lands) → the session is unviable: end the spin so the // (app) layout redirects to /login instead of hanging on the loader. authStore.setInitializing(false); } }, [me.data, me.error]); return { user, initializing, isAuthenticated: Boolean(user) }; } /** POST /auth/login; stores tokens, returns the created session. */ export function useLogin() { const router = useRouter(); const queryClient = useQueryClient(); const login = useCallback(async (input: LoginInput) => { const result = await authApi.login(input); authStore.setUser(result.user, result.accessToken); return result; }, []); const logout = useCallback(async () => { try { // Bearer-gated: the API resolves the user from the token to scope the // session revocation and clears the refresh cookie on the response. await authApi.logout(); } catch { /* best-effort — we clear local state regardless */ } authStore.clear(); // Drop the cached /me so the next login (possibly a different user) // re-fetches instead of reading the stale profile. queryClient.removeQueries({ queryKey: ["auth", "me"] }); // Logout = an explicit abandon (R1 recorded rule): a signed-out user's // queued offline mutations are discarded (the outbox is cleared, not // carried into the next session). const outbox = useOutboxStore.getState(); if (outbox.pending + outbox.failed > 0) { toast("info", "Your offline changes were discarded"); void outbox.clear(); } router.replace("/login"); }, [router, queryClient]); return { login, logout }; } /** POST /auth/register; auto-logs the new user in. */ export function useRegister() { const register = useCallback(async (input: RegisterInput) => { const result = await authApi.register(input); authStore.setUser(result.user, result.accessToken); return result; }, []); return { register }; }