fix: auth cookies cross-origin, async params, CSRF/web-vitals errors
- Set SameSite=lax for auth & CSRF cookies in development (cross-port) - Set refresh_token cookie path to / (was /auth, preventing cross-port send) - Await params in Next.js 15 async server components (layout, listings, agents) - Add CSRF token to web-vitals POST requests - Fix: 401 Unauthorized on all authenticated API calls from web app - Fix: CSRF token missing on POST requests from different port - Fix: params.locale sync access warning in generateMetadata Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,19 +41,21 @@ const ACCESS_TOKEN_MAX_AGE = 15 * 60 * 1000; // 15 minutes
|
|||||||
const REFRESH_TOKEN_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days
|
const REFRESH_TOKEN_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
const AUTH_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days
|
const AUTH_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
|
|
||||||
|
const SAME_SITE = IS_PRODUCTION ? 'strict' : 'lax';
|
||||||
|
|
||||||
function setAuthCookies(res: Response, tokens: TokenPair): void {
|
function setAuthCookies(res: Response, tokens: TokenPair): void {
|
||||||
res.cookie('access_token', tokens.accessToken, {
|
res.cookie('access_token', tokens.accessToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: IS_PRODUCTION,
|
secure: IS_PRODUCTION,
|
||||||
sameSite: 'strict',
|
sameSite: SAME_SITE,
|
||||||
path: '/',
|
path: '/',
|
||||||
maxAge: ACCESS_TOKEN_MAX_AGE,
|
maxAge: ACCESS_TOKEN_MAX_AGE,
|
||||||
});
|
});
|
||||||
res.cookie('refresh_token', tokens.refreshToken, {
|
res.cookie('refresh_token', tokens.refreshToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: IS_PRODUCTION,
|
secure: IS_PRODUCTION,
|
||||||
sameSite: 'strict',
|
sameSite: SAME_SITE,
|
||||||
path: '/auth', // Only sent to auth endpoints
|
path: '/',
|
||||||
maxAge: REFRESH_TOKEN_MAX_AGE,
|
maxAge: REFRESH_TOKEN_MAX_AGE,
|
||||||
});
|
});
|
||||||
res.cookie('goodgo_authenticated', '1', {
|
res.cookie('goodgo_authenticated', '1', {
|
||||||
@@ -67,7 +69,7 @@ function setAuthCookies(res: Response, tokens: TokenPair): void {
|
|||||||
|
|
||||||
function clearAuthCookies(res: Response): void {
|
function clearAuthCookies(res: Response): void {
|
||||||
res.clearCookie('access_token', { path: '/' });
|
res.clearCookie('access_token', { path: '/' });
|
||||||
res.clearCookie('refresh_token', { path: '/auth' });
|
res.clearCookie('refresh_token', { path: '/' });
|
||||||
res.clearCookie('goodgo_authenticated', { path: '/' });
|
res.clearCookie('goodgo_authenticated', { path: '/' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,10 +38,11 @@ export class CsrfMiddleware implements NestMiddleware {
|
|||||||
|
|
||||||
private setCsrfCookie(res: Response): void {
|
private setCsrfCookie(res: Response): void {
|
||||||
const token = randomBytes(TOKEN_LENGTH).toString('hex');
|
const token = randomBytes(TOKEN_LENGTH).toString('hex');
|
||||||
|
const isProduction = process.env['NODE_ENV'] === 'production';
|
||||||
res.cookie(CSRF_COOKIE, token, {
|
res.cookie(CSRF_COOKIE, token, {
|
||||||
httpOnly: false, // Frontend must read this cookie
|
httpOnly: false, // Frontend must read this cookie
|
||||||
secure: process.env['NODE_ENV'] === 'production',
|
secure: isProduction,
|
||||||
sameSite: 'strict',
|
sameSite: isProduction ? 'strict' : 'lax',
|
||||||
path: '/',
|
path: '/',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ const siteUrl = process.env['NEXT_PUBLIC_SITE_URL'] || 'https://goodgo.vn';
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: { locale: string; id: string };
|
params: Promise<{ locale: string; id: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
export async function generateMetadata({ params: paramsPromise }: PageProps): Promise<Metadata> {
|
||||||
|
const params = await paramsPromise;
|
||||||
const agent = await fetchAgentProfile(params.id);
|
const agent = await fetchAgentProfile(params.id);
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
return { title: 'Không tìm thấy môi giới' };
|
return { title: 'Không tìm thấy môi giới' };
|
||||||
@@ -82,7 +83,8 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|||||||
// Page (Server Component)
|
// Page (Server Component)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export default async function AgentProfilePage({ params }: PageProps) {
|
export default async function AgentProfilePage({ params: paramsPromise }: PageProps) {
|
||||||
|
const params = await paramsPromise;
|
||||||
const [agent, reviewsResult] = await Promise.all([
|
const [agent, reviewsResult] = await Promise.all([
|
||||||
fetchAgentProfile(params.id),
|
fetchAgentProfile(params.id),
|
||||||
fetchAgentReviews(params.id, 1, 10),
|
fetchAgentReviews(params.id, 1, 10),
|
||||||
|
|||||||
@@ -26,10 +26,11 @@ function getLabel(list: readonly { value: string; label: string }[], value: stri
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: { locale: string; id: string };
|
params: Promise<{ locale: string; id: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
export async function generateMetadata({ params: paramsPromise }: PageProps): Promise<Metadata> {
|
||||||
|
const params = await paramsPromise;
|
||||||
const listing = await fetchListingById(params.id);
|
const listing = await fetchListingById(params.id);
|
||||||
if (!listing) {
|
if (!listing) {
|
||||||
return { title: 'Kh\u00f4ng t\u00ecm th\u1ea5y tin \u0111\u0103ng' };
|
return { title: 'Kh\u00f4ng t\u00ecm th\u1ea5y tin \u0111\u0103ng' };
|
||||||
@@ -92,7 +93,8 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|||||||
// Page (Server Component)
|
// Page (Server Component)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export default async function PublicListingDetailPage({ params }: PageProps) {
|
export default async function PublicListingDetailPage({ params: paramsPromise }: PageProps) {
|
||||||
|
const params = await paramsPromise;
|
||||||
const listing = await fetchListingById(params.id);
|
const listing = await fetchListingById(params.id);
|
||||||
|
|
||||||
if (!listing) {
|
if (!listing) {
|
||||||
|
|||||||
@@ -27,10 +27,11 @@ export const viewport: Viewport = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params: { locale },
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: { locale: string };
|
params: Promise<{ locale: string }>;
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'metadata' });
|
const t = await getTranslations({ locale, namespace: 'metadata' });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -92,11 +93,13 @@ export function generateStaticParams() {
|
|||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function LocaleLayout({
|
||||||
children,
|
children,
|
||||||
params: { locale },
|
params,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: { locale: string };
|
params: Promise<{ locale: string }>;
|
||||||
}) {
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
|
||||||
// Validate locale
|
// Validate locale
|
||||||
if (!routing.locales.includes(locale as Locale)) {
|
if (!routing.locales.includes(locale as Locale)) {
|
||||||
notFound();
|
notFound();
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|||||||
const FLUSH_INTERVAL_MS = 5000;
|
const FLUSH_INTERVAL_MS = 5000;
|
||||||
const MAX_BATCH_SIZE = 10;
|
const MAX_BATCH_SIZE = 10;
|
||||||
|
|
||||||
|
function getCsrfToken(): string | undefined {
|
||||||
|
if (typeof document === 'undefined') return undefined;
|
||||||
|
const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/);
|
||||||
|
return match?.[1] ? decodeURIComponent(match[1]) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function flushQueue(): void {
|
function flushQueue(): void {
|
||||||
if (queue.length === 0) return;
|
if (queue.length === 0) return;
|
||||||
|
|
||||||
@@ -65,9 +71,14 @@ function flushQueue(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sendViaFetch(body: string): void {
|
function sendViaFetch(body: string): void {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
const csrfToken = getCsrfToken();
|
||||||
|
if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
|
||||||
|
|
||||||
fetch(`${API_BASE_URL}/web-vitals`, {
|
fetch(`${API_BASE_URL}/web-vitals`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
body,
|
body,
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user