refactor: standardize auth cookie configuration and improve logout reliability by explicitly expiring session tokens
This commit is contained in:
@@ -84,8 +84,6 @@ export default function AgentDashboard() {
|
|||||||
const [testimonials, setTestimonials] = useState<{ id: string; text: string; author: string; role: string; rating: number }[]>([]);
|
const [testimonials, setTestimonials] = useState<{ id: string; text: string; author: string; role: string; rating: number }[]>([]);
|
||||||
const [isSubmittingVerification, setIsSubmittingVerification] = useState(false);
|
const [isSubmittingVerification, setIsSubmittingVerification] = useState(false);
|
||||||
|
|
||||||
const defaultImage = '/assets/demo_images/8f017153a7b4a239a4f0691234ef97dd55092282.jpg';
|
|
||||||
|
|
||||||
// Helper to check if avatar is an S3 key (not a full URL or local path)
|
// Helper to check if avatar is an S3 key (not a full URL or local path)
|
||||||
const isS3Key = (avatar: string | null | undefined): boolean => {
|
const isS3Key = (avatar: string | null | undefined): boolean => {
|
||||||
if (!avatar) return false;
|
if (!avatar) return false;
|
||||||
@@ -220,20 +218,15 @@ export default function AgentDashboard() {
|
|||||||
window.dispatchEvent(new Event('profile-updated'));
|
window.dispatchEvent(new Event('profile-updated'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getProfileImageUrl = () => {
|
const getProfileImageUrl = (): string | null => {
|
||||||
// If image failed to load, return default
|
|
||||||
if (imageError) {
|
|
||||||
return defaultImage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have a presigned URL from S3, use it
|
// If we have a presigned URL from S3, use it
|
||||||
if (avatarUrl) {
|
if (avatarUrl) {
|
||||||
return avatarUrl;
|
return avatarUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for null, undefined, or empty string
|
// No avatar set
|
||||||
if (!agentProfile?.avatar || agentProfile.avatar.trim() === '') {
|
if (!agentProfile?.avatar || agentProfile.avatar.trim() === '') {
|
||||||
return defaultImage;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For relative paths (local assets), return as-is
|
// For relative paths (local assets), return as-is
|
||||||
@@ -241,8 +234,8 @@ export default function AgentDashboard() {
|
|||||||
return agentProfile.avatar;
|
return agentProfile.avatar;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default fallback
|
// No URL resolvable
|
||||||
return defaultImage;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -371,22 +364,25 @@ export default function AgentDashboard() {
|
|||||||
{/* Profile Image */}
|
{/* Profile Image */}
|
||||||
<div className="relative w-[200px] lg:w-[260px]">
|
<div className="relative w-[200px] lg:w-[260px]">
|
||||||
<div className="w-[200px] h-[200px] lg:w-[260px] lg:h-[260px] rounded-[15px] overflow-hidden relative">
|
<div className="w-[200px] h-[200px] lg:w-[260px] lg:h-[260px] rounded-[15px] overflow-hidden relative">
|
||||||
{/* Shimmer while loading, initials only on error */}
|
{/* Initials fallback as base layer */}
|
||||||
{imageError ? (
|
|
||||||
<div className="absolute inset-0 bg-[#c4d9d4] flex items-center justify-center rounded-[15px]">
|
<div className="absolute inset-0 bg-[#c4d9d4] flex items-center justify-center rounded-[15px]">
|
||||||
<span className="font-bold text-[#00293d] text-[80px]">{agentProfile?.firstName?.[0]?.toUpperCase() || '?'}</span>
|
<span className="font-bold text-[#00293d] text-[80px]">{agentProfile?.firstName?.[0]?.toUpperCase() || '?'}</span>
|
||||||
</div>
|
</div>
|
||||||
) : !imageLoaded ? (
|
{/* Shimmer while loading (only if we have a real image to load) */}
|
||||||
|
{getProfileImageUrl() && !imageLoaded && !imageError && (
|
||||||
<div className="absolute inset-0 shimmer-loading rounded-[15px]" />
|
<div className="absolute inset-0 shimmer-loading rounded-[15px]" />
|
||||||
) : null}
|
)}
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* Image overlay — only render if we have a URL */}
|
||||||
|
{getProfileImageUrl() && !imageError && (
|
||||||
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
<img
|
<img
|
||||||
src={getProfileImageUrl()}
|
src={getProfileImageUrl()!}
|
||||||
alt="Profile"
|
alt="Profile"
|
||||||
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${imageLoaded ? 'opacity-100' : 'opacity-0'}`}
|
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${imageLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||||
onLoad={() => setImageLoaded(true)}
|
onLoad={() => setImageLoaded(true)}
|
||||||
onError={() => setImageError(true)}
|
onError={() => setImageError(true)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
{/* Gradient Overlay */}
|
{/* Gradient Overlay */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-black/20 to-transparent pointer-events-none" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-black/20 to-transparent pointer-events-none" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { cookies } from 'next/headers';
|
|||||||
export async function clearAuthCookies() {
|
export async function clearAuthCookies() {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
// Delete all next-auth v5 cookie variants
|
// All possible NextAuth cookie names (v4 + v5, secure + host prefixed)
|
||||||
// Must specify path and secure options to match how they were set
|
|
||||||
const cookieNames = [
|
const cookieNames = [
|
||||||
'authjs.session-token',
|
'authjs.session-token',
|
||||||
'authjs.csrf-token',
|
'authjs.csrf-token',
|
||||||
@@ -14,27 +13,46 @@ export async function clearAuthCookies() {
|
|||||||
'__Secure-authjs.session-token',
|
'__Secure-authjs.session-token',
|
||||||
'__Secure-authjs.csrf-token',
|
'__Secure-authjs.csrf-token',
|
||||||
'__Secure-authjs.callback-url',
|
'__Secure-authjs.callback-url',
|
||||||
// next-auth v4 fallback names
|
|
||||||
'next-auth.session-token',
|
'next-auth.session-token',
|
||||||
'next-auth.csrf-token',
|
'next-auth.csrf-token',
|
||||||
'next-auth.callback-url',
|
'next-auth.callback-url',
|
||||||
'__Secure-next-auth.session-token',
|
'__Secure-next-auth.session-token',
|
||||||
'__Secure-next-auth.csrf-token',
|
'__Secure-next-auth.csrf-token',
|
||||||
'__Secure-next-auth.callback-url',
|
'__Secure-next-auth.callback-url',
|
||||||
// Host-prefixed variants (used when behind a proxy)
|
|
||||||
'__Host-authjs.csrf-token',
|
'__Host-authjs.csrf-token',
|
||||||
'__Host-next-auth.csrf-token',
|
'__Host-next-auth.csrf-token',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Aggressively expire each cookie by setting empty value + maxAge: 0
|
||||||
|
// This works better than .delete() in production behind load balancers
|
||||||
for (const name of cookieNames) {
|
for (const name of cookieNames) {
|
||||||
// Delete with explicit options to ensure cookie is actually removed
|
try {
|
||||||
cookieStore.delete({
|
cookieStore.set({
|
||||||
name,
|
name,
|
||||||
|
value: '',
|
||||||
path: '/',
|
path: '/',
|
||||||
|
maxAge: 0,
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'lax',
|
||||||
});
|
});
|
||||||
|
} catch {
|
||||||
|
// Some Secure cookies cannot be set in non-HTTPS environments
|
||||||
|
}
|
||||||
|
// Also try non-secure version
|
||||||
|
try {
|
||||||
|
cookieStore.set({
|
||||||
|
name,
|
||||||
|
value: '',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 0,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also try getting all cookies and deleting any auth-related ones
|
// Sweep: any remaining cookies that look auth-related → expire them
|
||||||
const allCookies = cookieStore.getAll();
|
const allCookies = cookieStore.getAll();
|
||||||
for (const cookie of allCookies) {
|
for (const cookie of allCookies) {
|
||||||
if (
|
if (
|
||||||
@@ -42,7 +60,24 @@ export async function clearAuthCookies() {
|
|||||||
cookie.name.includes('next-auth') ||
|
cookie.name.includes('next-auth') ||
|
||||||
cookie.name.includes('session-token')
|
cookie.name.includes('session-token')
|
||||||
) {
|
) {
|
||||||
cookieStore.delete({ name: cookie.name, path: '/' });
|
try {
|
||||||
|
cookieStore.set({
|
||||||
|
name: cookie.name,
|
||||||
|
value: '',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 0,
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
cookieStore.set({ name: cookie.name, value: '', path: '/', maxAge: 0 });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,25 +33,30 @@ export default function LogoutPage() {
|
|||||||
localStorage.removeItem('refreshToken');
|
localStorage.removeItem('refreshToken');
|
||||||
localStorage.removeItem('user');
|
localStorage.removeItem('user');
|
||||||
|
|
||||||
// Delete httpOnly cookies via server action FIRST
|
// Call NextAuth signOut — canonical way to clear session cookie
|
||||||
// This ensures the session cookie is gone before signOut triggers session checks
|
// (uses the exact cookie name/attributes from cookies config in auth.ts)
|
||||||
await clearAuthCookies();
|
try {
|
||||||
|
|
||||||
// Client-side signOut to clear NextAuth client state
|
|
||||||
await signOut({ redirect: false });
|
await signOut({ redirect: false });
|
||||||
|
} catch {
|
||||||
|
// Continue even if signOut fails
|
||||||
|
}
|
||||||
|
|
||||||
// Delete cookies again after signOut in case signOut recreated any
|
// Belt-and-suspenders: server action to nuke any remaining auth cookies
|
||||||
|
try {
|
||||||
await clearAuthCookies();
|
await clearAuthCookies();
|
||||||
|
} catch {
|
||||||
|
// Continue
|
||||||
|
}
|
||||||
|
|
||||||
// Clear the logout flag before redirecting so it doesn't block API calls on public pages
|
// Clear the logout flag before redirecting
|
||||||
localStorage.removeItem('isLoggingOut');
|
localStorage.removeItem('isLoggingOut');
|
||||||
|
|
||||||
// Redirect to login immediately
|
// Hard reload (replace, not push) so all in-memory state and cached responses are flushed
|
||||||
window.location.href = '/';
|
window.location.replace('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
performLogout().catch(() => {
|
performLogout().catch(() => {
|
||||||
window.location.href = '/';
|
window.location.replace('/');
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
30
src/auth.ts
30
src/auth.ts
@@ -240,5 +240,35 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||||||
maxAge: 7 * 24 * 60 * 60, // 7 days
|
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Explicit cookie configuration so we can reliably clear them on logout
|
||||||
|
cookies: {
|
||||||
|
sessionToken: {
|
||||||
|
name: process.env.NODE_ENV === 'production' ? '__Secure-authjs.session-token' : 'authjs.session-token',
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
callbackUrl: {
|
||||||
|
name: process.env.NODE_ENV === 'production' ? '__Secure-authjs.callback-url' : 'authjs.callback-url',
|
||||||
|
options: {
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
csrfToken: {
|
||||||
|
name: process.env.NODE_ENV === 'production' ? '__Host-authjs.csrf-token' : 'authjs.csrf-token',
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
secret: process.env.NEXTAUTH_SECRET,
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user