fix: self-healing session — stale/expired sessions no longer crash the app

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.
This commit is contained in:
Afra ‌ 2026-08-19 10:26:02 +03:30
parent bc6d66423c
commit 573f43ea41
10 changed files with 102 additions and 39 deletions

View file

@ -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 (

View file

@ -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);

View file

@ -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");

View file

@ -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 (
<div className="flex min-h-full flex-1 flex-col">

View file

@ -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();

View file

@ -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 (

View file

@ -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 (
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-3 px-4 py-6">
<h1 className="text-title-large font-extrabold text-foreground">پروفایل</h1>

View file

@ -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([

View file

@ -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<RequestInit, "body"> {
body?: unknown;
}
async function getValidAccessToken(): Promise<string | null> {
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<T> {
count: number;
next: string | null;
@ -58,8 +35,8 @@ export async function winofyFetch<T>(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(), {

View file

@ -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,6 +27,7 @@ export async function createSession(tokens: SessionTokens) {
.encrypt(encodedKey);
const store = await cookies();
try {
store.set(COOKIE_NAME, jwe, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
@ -31,8 +35,22 @@ export async function createSession(tokens: SessionTokens) {
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<SessionTokens | null> {
const store = await cookies();
const value = store.get(COOKIE_NAME)?.value;
@ -47,7 +65,63 @@ export async function getSession(): Promise<SessionTokens | null> {
export async function deleteSession() {
const store = await cookies();
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<SessionTokens | null> => {
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<SessionTokens> {
const session = await getValidSession();
if (!session) {
redirect(`/login?next=${encodeURIComponent(currentPath)}`);
}
return session;
}
export const SESSION_COOKIE_NAME = COOKIE_NAME;