From 573f43ea411d1e7352f0a911a16c8d807aa34f68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afra=20=E2=80=8C?= Date: Wed, 19 Aug 2026 10:26:02 +0330 Subject: [PATCH] =?UTF-8?q?fix:=20self-healing=20session=20=E2=80=94=20sta?= =?UTF-8?q?le/expired=20sessions=20no=20longer=20crash=20the=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the home-page 500 ("اطلاعات برای اعتبارسنجی ارسال نشده است", 401 not_authenticated): getSession() only decrypted the session cookie, it never checked whether the access token inside it had actually expired. The JWE wrapper lives 30 days; the real Gooyal access token lives ~10 hours (expires_in: 36000). So isAuthenticated stayed true long after the token died, the home page called getCart() anyway, winofyFetch's refresh attempt failed silently (dead refresh token) and returned no Authorization header at all, and the resulting 401 was never caught -- crashing the whole page. Fix: getValidSession() (session.ts) is now the single source of truth -- refreshes when possible, self-heals by clearing the cookie when refresh fails, deduped per-request via React's cache(). winofyFetch and every isAuthenticated check (home page, shop layout) now use it instead of the raw cookie read. That surfaced a second bug: Next.js forbids writing cookies during a plain Server Component render (Server Actions/Route Handlers only), so getValidSession()'s self-heal itself crashed when called from a page like home. createSession()/deleteSession() now swallow that specific failure -- the refreshed/cleared session is still correct for the rest of the current request, it just won't persist when called from a context that can't write cookies (the next request re-derives the same correct answer). Also added requireSession(path) and wired it into every auth-required page (cart, checkout, addresses, orders, order-groups/[uuid], profile) -- proxy.ts's gate is deliberately optimistic (cookie presence only, per Next.js's own guidance), so a present-but-dead session was reaching these pages and crashing the same way; they now redirect to /login instead. Verified against the exact failure: a session with a dead access+refresh token now renders the home page as logged-out (200, not 500) and redirects /cart to /login (307) instead of crashing. Also verified the happy path (a genuinely fresh, valid session from a real OTP login) still works. --- src/app/(customer)/(shop)/addresses/page.tsx | 2 + src/app/(customer)/(shop)/cart/page.tsx | 2 + src/app/(customer)/(shop)/checkout/page.tsx | 2 + src/app/(customer)/(shop)/layout.tsx | 4 +- .../(shop)/order-groups/[uuid]/page.tsx | 2 + src/app/(customer)/(shop)/orders/page.tsx | 2 + src/app/(customer)/(shop)/profile/page.tsx | 4 +- src/app/(customer)/page.tsx | 4 +- src/lib/api/winofy.ts | 29 +----- src/lib/auth/session.ts | 90 +++++++++++++++++-- 10 files changed, 102 insertions(+), 39 deletions(-) diff --git a/src/app/(customer)/(shop)/addresses/page.tsx b/src/app/(customer)/(shop)/addresses/page.tsx index 9698b76..505294d 100644 --- a/src/app/(customer)/(shop)/addresses/page.tsx +++ b/src/app/(customer)/(shop)/addresses/page.tsx @@ -1,4 +1,5 @@ import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy"; +import { requireSession } from "@/lib/auth/session"; import type { Address, City, Neighborhood } from "@/types/api"; import { AddressList } from "@/components/customer/address-list"; @@ -21,6 +22,7 @@ async function getNeighborhoods() { } export default async function AddressesPage() { + await requireSession("/addresses"); const [addresses, cities, neighborhoods] = await Promise.all([getAddresses(), getCities(), getNeighborhoods()]); return ( diff --git a/src/app/(customer)/(shop)/cart/page.tsx b/src/app/(customer)/(shop)/cart/page.tsx index 4bacd17..331d8ec 100644 --- a/src/app/(customer)/(shop)/cart/page.tsx +++ b/src/app/(customer)/(shop)/cart/page.tsx @@ -1,9 +1,11 @@ import Link from "next/link"; import { getCart } from "@/lib/actions/cart"; +import { requireSession } from "@/lib/auth/session"; import { CartItemRow } from "@/components/customer/cart-item-row"; import { Button } from "@/components/ui/button"; export default async function CartPage() { + await requireSession("/cart"); const cart = await getCart(); const hasBlockedGroup = cart.groups.some((g) => !g.meets_minimum_order); diff --git a/src/app/(customer)/(shop)/checkout/page.tsx b/src/app/(customer)/(shop)/checkout/page.tsx index 57dfcff..d60797f 100644 --- a/src/app/(customer)/(shop)/checkout/page.tsx +++ b/src/app/(customer)/(shop)/checkout/page.tsx @@ -1,5 +1,6 @@ import { redirect } from "next/navigation"; import { getCart } from "@/lib/actions/cart"; +import { requireSession } from "@/lib/auth/session"; import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy"; import type { Address } from "@/types/api"; import { CheckoutForm } from "@/components/customer/checkout-form"; @@ -10,6 +11,7 @@ async function getAddresses() { } export default async function CheckoutPage() { + await requireSession("/checkout"); const [cart, addresses] = await Promise.all([getCart(), getAddresses()]); if (cart.groups.length === 0) redirect("/cart"); diff --git a/src/app/(customer)/(shop)/layout.tsx b/src/app/(customer)/(shop)/layout.tsx index dc61bbe..5ed4d52 100644 --- a/src/app/(customer)/(shop)/layout.tsx +++ b/src/app/(customer)/(shop)/layout.tsx @@ -1,9 +1,9 @@ import Link from "next/link"; -import { getSession } from "@/lib/auth/session"; +import { getValidSession } from "@/lib/auth/session"; import { LogoutButton } from "@/components/customer/logout-button"; export default async function ShopLayout({ children }: LayoutProps<"/">) { - const session = await getSession(); + const session = await getValidSession(); return (
diff --git a/src/app/(customer)/(shop)/order-groups/[uuid]/page.tsx b/src/app/(customer)/(shop)/order-groups/[uuid]/page.tsx index c435c80..5563862 100644 --- a/src/app/(customer)/(shop)/order-groups/[uuid]/page.tsx +++ b/src/app/(customer)/(shop)/order-groups/[uuid]/page.tsx @@ -1,5 +1,6 @@ import { notFound } from "next/navigation"; import { winofyFetch, ApiError } from "@/lib/api/winofy"; +import { requireSession } from "@/lib/auth/session"; import { OrderCard } from "@/components/customer/order-card"; import { PAYMENT_STATUS_LABELS_FA } from "@/lib/order-status"; import type { OrderGroup } from "@/types/api"; @@ -15,6 +16,7 @@ async function getOrderGroup(uuid: string) { export default async function OrderGroupPage({ params }: PageProps<"/order-groups/[uuid]">) { const { uuid } = await params; + await requireSession(`/order-groups/${uuid}`); const orderGroup = await getOrderGroup(uuid); if (!orderGroup) notFound(); diff --git a/src/app/(customer)/(shop)/orders/page.tsx b/src/app/(customer)/(shop)/orders/page.tsx index 76af803..f20fc51 100644 --- a/src/app/(customer)/(shop)/orders/page.tsx +++ b/src/app/(customer)/(shop)/orders/page.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy"; +import { requireSession } from "@/lib/auth/session"; import { PAYMENT_STATUS_LABELS_FA } from "@/lib/order-status"; import type { OrderGroup } from "@/types/api"; @@ -9,6 +10,7 @@ async function getOrderGroups() { } export default async function OrdersPage() { + await requireSession("/orders"); const orderGroups = await getOrderGroups(); return ( diff --git a/src/app/(customer)/(shop)/profile/page.tsx b/src/app/(customer)/(shop)/profile/page.tsx index b96a46c..415b538 100644 --- a/src/app/(customer)/(shop)/profile/page.tsx +++ b/src/app/(customer)/(shop)/profile/page.tsx @@ -1,9 +1,11 @@ import Link from "next/link"; +import { requireSession } from "@/lib/auth/session"; import { LogoutButton } from "@/components/customer/logout-button"; // Minimal placeholder — winofyfrontenddoc.md's bottom-nav references a "پروفایل" // tab, but no profile screen is documented yet in either doc. -export default function ProfilePage() { +export default async function ProfilePage() { + await requireSession("/profile"); return (

پروفایل

diff --git a/src/app/(customer)/page.tsx b/src/app/(customer)/page.tsx index 0c57d04..37683c8 100644 --- a/src/app/(customer)/page.tsx +++ b/src/app/(customer)/page.tsx @@ -1,4 +1,4 @@ -import { getSession } from "@/lib/auth/session"; +import { getValidSession } from "@/lib/auth/session"; import { getLocationServer } from "@/lib/location/server"; import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy"; import { getCart } from "@/lib/actions/cart"; @@ -31,7 +31,7 @@ export default async function HomePage({ searchParams }: PageProps<"/">) { const category = typeof params.category === "string" ? params.category : undefined; const search = typeof params.search === "string" ? params.search : undefined; - const [session, location] = await Promise.all([getSession(), getLocationServer()]); + const [session, location] = await Promise.all([getValidSession(), getLocationServer()]); const isAuthenticated = !!session; const [categories, stores, cart] = await Promise.all([ diff --git a/src/lib/api/winofy.ts b/src/lib/api/winofy.ts index 8e94c70..942e336 100644 --- a/src/lib/api/winofy.ts +++ b/src/lib/api/winofy.ts @@ -1,6 +1,5 @@ import "server-only"; -import { getSession, createSession } from "@/lib/auth/session"; -import { refreshAccessToken } from "@/lib/auth/gooyal"; +import { getValidSession } from "@/lib/auth/session"; import { ApiError, type WinofyErrorDetails } from "./errors"; const BASE_URL = process.env.WINOFY_API_BASE_URL!; @@ -12,28 +11,6 @@ interface WinofyFetchOptions extends Omit { body?: unknown; } -async function getValidAccessToken(): Promise { - const session = await getSession(); - if (!session) return null; - - // 30s skew so we don't hand out a token that expires mid-request. - if (Date.now() < session.expiresAt - 30_000) return session.accessToken; - if (!session.refreshToken) return session.accessToken; - - try { - const refreshed = await refreshAccessToken(session.refreshToken); - await createSession({ - accessToken: refreshed.access_token, - refreshToken: refreshed.refresh_token ?? session.refreshToken, - tokenType: refreshed.token_type, - expiresAt: Date.now() + refreshed.expires_in * 1000, - }); - return refreshed.access_token; - } catch { - return null; - } -} - export interface PaginatedResponse { count: number; next: string | null; @@ -58,8 +35,8 @@ export async function winofyFetch(path: string, options: WinofyFetchOptions = } if (auth) { - const token = await getValidAccessToken(); - if (token) finalHeaders.set("Authorization", `Bearer ${token}`); + const session = await getValidSession(); + if (session) finalHeaders.set("Authorization", `Bearer ${session.accessToken}`); } const res = await fetch(url.toString(), { diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 8e5d947..a27f9e8 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -1,6 +1,9 @@ import "server-only"; +import { cache } from "react"; +import { redirect } from "next/navigation"; import { EncryptJWT, jwtDecrypt } from "jose"; import { cookies } from "next/headers"; +import { refreshAccessToken } from "./gooyal"; const secret = process.env.SESSION_SECRET; if (!secret) throw new Error("SESSION_SECRET is not set"); @@ -24,15 +27,30 @@ export async function createSession(tokens: SessionTokens) { .encrypt(encodedKey); const store = await cookies(); - store.set(COOKIE_NAME, jwe, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - path: "/", - maxAge: 60 * 60 * 24 * 30, - }); + try { + store.set(COOKIE_NAME, jwe, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24 * 30, + }); + } catch { + // Next.js forbids writing cookies during a plain Server Component render + // (only Server Actions/Route Handlers may). getValidSession() calls this + // from both — when called from a render, the refreshed token still gets + // used for the rest of *this* request (the caller has the return value), + // it just won't persist; the next request re-runs the same refresh. Never + // let that turn into a crash. + } } +/** + * Raw read — decrypts the cookie but does NOT check whether the access token + * inside it has expired. The JWE wrapper lives for 30 days regardless of the + * real Gooyal token's much shorter `expires_in`, so a truthy result here does + * NOT mean the session is still usable against the API. Prefer getValidSession(). + */ export async function getSession(): Promise { const store = await cookies(); const value = store.get(COOKIE_NAME)?.value; @@ -47,7 +65,63 @@ export async function getSession(): Promise { export async function deleteSession() { const store = await cookies(); - store.delete(COOKIE_NAME); + try { + store.delete(COOKIE_NAME); + } catch { + // Same rationale as createSession()'s catch — safe to no-op here. + } +} + +/** + * The actual source of truth for "is this user logged in" — refreshes an + * expired access token when possible, and self-heals by clearing the cookie + * when it isn't (dead/expired refresh token), rather than leaving a session + * that decrypts fine but 401s on every real API call. Deduped per request via + * React's cache() since both a layout's isAuthenticated check and any + * winofyFetch call on the same page would otherwise each trigger their own + * refresh attempt. + */ +export const getValidSession = cache(async (): Promise => { + const session = await getSession(); + if (!session) return null; + + // 30s skew so we don't hand out a token that expires mid-request. + if (Date.now() < session.expiresAt - 30_000) return session; + + if (!session.refreshToken) { + await deleteSession(); + return null; + } + + try { + const refreshed = await refreshAccessToken(session.refreshToken); + const nextSession: SessionTokens = { + accessToken: refreshed.access_token, + refreshToken: refreshed.refresh_token ?? session.refreshToken, + tokenType: refreshed.token_type, + expiresAt: Date.now() + refreshed.expires_in * 1000, + }; + await createSession(nextSession); + return nextSession; + } catch { + await deleteSession(); + return null; + } +}); + +/** + * For pages under proxy.ts's auth-required prefixes: the proxy only checks + * cookie *presence* (optimistic — see proxy.ts), so a present-but-dead session + * still reaches the page. Call this at the top of any such page instead of + * fetching data straight away, so a dead session redirects to /login instead + * of the page crashing on an uncaught 401 from winofyFetch. + */ +export async function requireSession(currentPath: string): Promise { + const session = await getValidSession(); + if (!session) { + redirect(`/login?next=${encodeURIComponent(currentPath)}`); + } + return session; } export const SESSION_COOKIE_NAME = COOKIE_NAME;