From 0b7c97e4518504d60d48a7bc8e74857785673d8d Mon Sep 17 00:00:00 2001 From: Paul Ochieng Levi Date: Tue, 9 Jun 2026 21:37:40 +0300 Subject: [PATCH 1/2] feat: enhance NextAuth configuration and user session management with additional fields and cookie domain validation --- src/platform/.env.example | 1 + src/platform/middleware.ts | 2 +- src/platform/src/next-auth.d.ts | 18 +++ src/platform/src/shared/hooks/useLogout.ts | 13 +-- src/platform/src/shared/lib/auth.ts | 109 +++++++++++++++--- src/vertex/.env.example | 1 + .../app/api/auth/[...nextauth]/options.ts | 10 +- src/vertex/core/hooks/useLogout.ts | 9 +- src/vertex/middleware.ts | 4 +- src/vertex/types/next-auth.d.ts | 8 ++ 10 files changed, 144 insertions(+), 31 deletions(-) diff --git a/src/platform/.env.example b/src/platform/.env.example index f24b4e5468..e5ba464a00 100644 --- a/src/platform/.env.example +++ b/src/platform/.env.example @@ -1,6 +1,7 @@ # NextAuth NEXTAUTH_SECRET= NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_COOKIE_DOMAIN=.airqo.net # App URLs NEXT_PUBLIC_BASE_URL=http://localhost:3000 diff --git a/src/platform/middleware.ts b/src/platform/middleware.ts index 3f284632e3..c38fc8ee77 100644 --- a/src/platform/middleware.ts +++ b/src/platform/middleware.ts @@ -3,7 +3,7 @@ import { withAuth } from 'next-auth/middleware'; const isProduction = process.env.NODE_ENV === 'production'; const sessionCookieName = isProduction ? '__Secure-next-auth.session-token' - : 'analytics.next-auth.session-token'; + : 'next-auth.session-token'; export default withAuth( function middleware() { diff --git a/src/platform/src/next-auth.d.ts b/src/platform/src/next-auth.d.ts index ef7973cf41..5167be65e4 100644 --- a/src/platform/src/next-auth.d.ts +++ b/src/platform/src/next-auth.d.ts @@ -5,6 +5,11 @@ type SessionUser = NonNullable & { _id?: string; firstName?: string; lastName?: string; + userName?: string; + organization?: string; + privilege?: string; + country?: string; + phoneNumber?: string; }; declare module 'next-auth' { @@ -18,6 +23,12 @@ declare module 'next-auth' { name?: string | null; image?: string | null; authMethods?: AuthMethods; + userName?: string; + organization?: string; + privilege?: string; + country?: string; + phoneNumber?: string; + exp?: number; } interface Session extends DefaultSession { @@ -36,5 +47,12 @@ declare module 'next-auth/jwt' { accessToken?: string; expiresAt?: string; authMethods?: AuthMethods; + userName?: string; + organization?: string; + privilege?: string; + country?: string; + phoneNumber?: string; + image?: string; + exp?: number; } } diff --git a/src/platform/src/shared/hooks/useLogout.ts b/src/platform/src/shared/hooks/useLogout.ts index e885a2e06d..996f93ca2b 100644 --- a/src/platform/src/shared/hooks/useLogout.ts +++ b/src/platform/src/shared/hooks/useLogout.ts @@ -2,7 +2,6 @@ import { signOut } from 'next-auth/react'; import { useDispatch } from 'react-redux'; import { clearUser, setLoggingOut } from '@/shared/store/userSlice'; import { persistor } from '@/shared/store'; -import { useRouter } from 'next/navigation'; import { useSelector } from 'react-redux'; import { selectLoggingOut } from '@/shared/store/selectors'; import { useCallback } from 'react'; @@ -19,7 +18,6 @@ const OAUTH_SIGNED_OUT_FLAG = 'airqo:oauth-signed-out'; export const useLogout = (callbackUrl?: string) => { const dispatch = useDispatch(); - const router = useRouter(); const isLoggingOut = useSelector(selectLoggingOut); const { cache, mutate } = useSWRConfig(); const queryClient = useQueryClient(); @@ -118,14 +116,15 @@ export const useLogout = (callbackUrl?: string) => { // Clear persisted Redux data await persistor.purge(); - // Sign out from NextAuth and redirect - await signOut({ callbackUrl: callbackUrl || '/user/login' }); + // Sign out from NextAuth (clear cookie server-side) then force full page reload + await signOut({ redirect: false }); + window.location.href = callbackUrl || '/user/login'; } catch (error) { logger.error('Logout error in useLogout', error); // Reset logging out state on error dispatch(setLoggingOut(false)); - // Fallback redirect - router.push(callbackUrl || '/user/login'); + // Force full page reload to clear all session state + window.location.href = callbackUrl || '/user/login'; } finally { sharedIsLoggingOut = false; sharedLogoutPromise = null; @@ -134,7 +133,7 @@ export const useLogout = (callbackUrl?: string) => { sharedLogoutPromise = runLogout(); await sharedLogoutPromise; - }, [cache, callbackUrl, dispatch, isLoggingOut, mutate, queryClient, router]); + }, [cache, callbackUrl, dispatch, isLoggingOut, mutate, queryClient]); return logout; }; diff --git a/src/platform/src/shared/lib/auth.ts b/src/platform/src/shared/lib/auth.ts index 36abe275db..ec4d3bf2b5 100644 --- a/src/platform/src/shared/lib/auth.ts +++ b/src/platform/src/shared/lib/auth.ts @@ -9,9 +9,55 @@ import { import type { AuthMethods } from '@/shared/types/api'; const isProduction = process.env.NODE_ENV === 'production'; +const configuredCookieDomain = + process.env.NEXTAUTH_COOKIE_DOMAIN?.trim() || undefined; + +const getCookieDomain = () => { + if (!configuredCookieDomain) { + return undefined; + } + + const referenceUrl = process.env.NEXTAUTH_URL; + if (!referenceUrl) { + return configuredCookieDomain; + } + + try { + const host = new URL(referenceUrl).hostname.toLowerCase(); + const normalizedDomain = configuredCookieDomain.replace(/^\./, '').toLowerCase(); + const hostMatches = + host === normalizedDomain || host.endsWith(`.${normalizedDomain}`); + + if (hostMatches) { + return configuredCookieDomain; + } + + console.warn( + '[NextAuth] NEXTAUTH_COOKIE_DOMAIN does not match NEXTAUTH_URL host; disabling cookie domain override.', + { configuredCookieDomain, host } + ); + return undefined; + } catch { + console.warn( + '[NextAuth] Invalid NEXTAUTH_URL while validating cookie domain; disabling cookie domain override.', + { configuredCookieDomain, referenceUrl } + ); + return undefined; + } +}; + +const cookieDomain = getCookieDomain(); +const cookieOptions = { + httpOnly: true, + sameSite: 'lax' as const, + path: '/', + secure: isProduction, + domain: cookieDomain, +}; + const sessionCookieName = isProduction ? '__Secure-next-auth.session-token' - : 'analytics.next-auth.session-token'; + : 'next-auth.session-token'; const isJwtLikeToken = (token: string): boolean => token.split('.').length === 3; @@ -119,16 +165,35 @@ export const authOptions: any = { .join(' ') .trim(); + let decoded: Record | null = null; + try { + const parts = oauthToken.split('.'); + if (parts.length === 3) { + const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); + decoded = JSON.parse(Buffer.from(padded, 'base64').toString('utf-8')); + } + } catch { + decoded = null; + } + return { id: profile._id, email: profile.email, name: fullName || profile.email, - firstName: profile.firstName, - lastName: profile.lastName, image: profile.profilePicture, _id: profile._id, accessToken: oauthToken, authMethods: normalizeAuthMethods(profile.authMethods), + firstName: profile.firstName, + lastName: profile.lastName, + userName: (decoded?.userName as string) || profile.email, + organization: (decoded?.organization as string) || '', + privilege: (decoded?.privilege as string) || '', + country: (decoded?.country as string) || '', + phoneNumber: (decoded?.phoneNumber as string) || '', + expiresAt: (decoded?.expiresAt as string) || '', + exp: (decoded?.exp as number) || 0, }; } @@ -150,18 +215,24 @@ export const authOptions: any = { password, }); - // Return user object for NextAuth + // Return user object for NextAuth with all fields for cross-app SSO return { id: loginData._id, + _id: loginData._id, email: loginData.email, name: `${loginData.firstName} ${loginData.lastName}`, - firstName: loginData.firstName, - lastName: loginData.lastName, image: loginData.profilePicture, - _id: loginData._id, accessToken: normalizeOAuthAccessToken(loginData.token), expiresAt: loginData.expiresAt, authMethods: normalizeAuthMethods(loginData.authMethods), + firstName: loginData.firstName, + lastName: loginData.lastName, + userName: loginData.userName, + organization: loginData.organization, + privilege: loginData.privilege, + country: loginData.country, + phoneNumber: loginData.phoneNumber || '', + exp: 0, }; } catch (error: any) { // Enhanced error handling to include status and full response data @@ -181,12 +252,7 @@ export const authOptions: any = { cookies: { sessionToken: { name: sessionCookieName, - options: { - httpOnly: true, - sameSite: 'lax' as const, - path: '/', - secure: isProduction, - }, + options: cookieOptions, }, }, pages: { @@ -210,6 +276,13 @@ export const authOptions: any = { token.firstName = user.firstName; token.lastName = user.lastName; token.authMethods = normalizeAuthMethods(user.authMethods); + token.userName = user.userName || user.email; + token.organization = user.organization || ''; + token.privilege = user.privilege || ''; + token.country = user.country || ''; + token.phoneNumber = user.phoneNumber || ''; + token.image = user.image; + token.exp = user.exp || 0; } if (trigger === 'update' && session) { @@ -252,9 +325,17 @@ export const authOptions: any = { (session as any).expiresAt = expiresAt; (session as any).authMethods = authMethods; if (session.user) { - (session.user as any)._id = (token as any)._id; + (session.user as any)._id = (token as any)._id || (token as any).id; (session.user as any).firstName = (token as any).firstName; (session.user as any).lastName = (token as any).lastName; + (session.user as any).userName = (token as any).userName || (session.user as any).email; + (session.user as any).organization = (token as any).organization || ''; + (session.user as any).privilege = (token as any).privilege || ''; + (session.user as any).country = (token as any).country || ''; + (session.user as any).phoneNumber = (token as any).phoneNumber || ''; + if ((token as any).image) { + (session.user as any).image = (token as any).image; + } } return session; }, diff --git a/src/vertex/.env.example b/src/vertex/.env.example index 0ae37dbaef..aa3dd6ce46 100644 --- a/src/vertex/.env.example +++ b/src/vertex/.env.example @@ -1,5 +1,6 @@ NEXTAUTH_SECRET= NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_COOKIE_DOMAIN=.airqo.net NEXT_PUBLIC_SLACK_BOT_TOKEN= NEXT_PUBLIC_SLACK_CHANNEL=notifs-airqo-netmanager-web diff --git a/src/vertex/app/api/auth/[...nextauth]/options.ts b/src/vertex/app/api/auth/[...nextauth]/options.ts index ff41ec856c..e5eb3f8706 100644 --- a/src/vertex/app/api/auth/[...nextauth]/options.ts +++ b/src/vertex/app/api/auth/[...nextauth]/options.ts @@ -211,6 +211,7 @@ export const options: NextAuthOptions = { id: profile._id, email: profile.email, name: `${profile.firstName} ${profile.lastName}`.trim() || profile.email, + image: profile.profilePicture || decoded?.profilePicture || '', userName: profile.userName || decoded?.userName || profile.email, accessToken: oauthToken, organization: profile.organization || decoded?.organization || '', @@ -254,6 +255,7 @@ export const options: NextAuthOptions = { id: decoded._id, email: decoded.email, name: `${decoded.firstName} ${decoded.lastName}`, + image: decoded.profilePicture || '', userName: decoded.userName, accessToken: loginResponse.token, organization: decoded.organization, @@ -288,8 +290,8 @@ export const options: NextAuthOptions = { cookies: { sessionToken: { name: isProduction - ? '__Secure-next-auth.session-token-v2' - : 'next-auth.session-token-v2', + ? '__Secure-next-auth.session-token' + : 'next-auth.session-token', options: cookieOptions, }, }, @@ -307,6 +309,7 @@ export const options: NextAuthOptions = { async jwt({ token, user }) { if (user) { token.id = user.id; + token._id = (user._id as string | undefined) || user.id; token.accessToken = user.accessToken; token.userName = user.userName; token.organization = user.organization; @@ -316,6 +319,7 @@ export const options: NextAuthOptions = { token.country = user.country; token.timezone = user.timezone; token.phoneNumber = user.phoneNumber; + token.image = user.image ?? undefined; token.exp = user.exp; } return token; @@ -330,6 +334,7 @@ export const options: NextAuthOptions = { session.user = { ...session.user, id: token.id as string, + _id: token.id as string, accessToken: token.accessToken as string, userName: token.userName as string, organization: token.organization as string, @@ -339,6 +344,7 @@ export const options: NextAuthOptions = { country: token.country as string, timezone: token.timezone as string, phoneNumber: token.phoneNumber as string, + image: (token.image as string) || '', exp: token.exp, }; } diff --git a/src/vertex/core/hooks/useLogout.ts b/src/vertex/core/hooks/useLogout.ts index 0b38e5f78d..abe89d07b2 100644 --- a/src/vertex/core/hooks/useLogout.ts +++ b/src/vertex/core/hooks/useLogout.ts @@ -1,6 +1,6 @@ import { signOut } from 'next-auth/react'; import { logout as clearUser, setLoggingOut } from '@/core/redux/slices/userSlice'; -import { useRouter, usePathname } from 'next/navigation'; +import { usePathname } from 'next/navigation'; import { useCallback } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { clearSessionData } from '../utils/sessionManager'; @@ -21,7 +21,6 @@ let sharedIsLoggingOut = false; */ export const useLogout = (callbackUrl?: string) => { const dispatch = useAppDispatch(); - const router = useRouter(); const pathname = usePathname(); const queryClient = useQueryClient(); const isLoggingOut = useAppSelector((state) => state.user.isLoggingOut); @@ -66,11 +65,11 @@ export const useLogout = (callbackUrl?: string) => { await persistor.purge(); await signOut({ redirect: false }); - router.push(callbackUrl || '/login'); + window.location.href = callbackUrl || '/login'; } catch (error) { logger.error('Logout error:', { error }); dispatch(setLoggingOut(false)); - router.push(callbackUrl || '/login'); + window.location.href = callbackUrl || '/login'; } finally { sharedIsLoggingOut = false; sharedLogoutPromise = null; @@ -79,7 +78,7 @@ export const useLogout = (callbackUrl?: string) => { sharedLogoutPromise = runLogout(); await sharedLogoutPromise; - }, [isLoggingOut, dispatch, queryClient, router, pathname, userDetails, callbackUrl]); + }, [isLoggingOut, dispatch, queryClient, pathname, userDetails, callbackUrl]); return logout; }; diff --git a/src/vertex/middleware.ts b/src/vertex/middleware.ts index b315466aa4..4790e9ee89 100644 --- a/src/vertex/middleware.ts +++ b/src/vertex/middleware.ts @@ -3,8 +3,8 @@ import { NextResponse } from "next/server"; const isProduction = process.env.NODE_ENV === "production"; const sessionCookieName = isProduction - ? "__Secure-next-auth.session-token-v2" - : "next-auth.session-token-v2"; + ? "__Secure-next-auth.session-token" + : "next-auth.session-token"; export default withAuth( function middleware(req) { diff --git a/src/vertex/types/next-auth.d.ts b/src/vertex/types/next-auth.d.ts index 6f4e5d00f6..82f33398ce 100644 --- a/src/vertex/types/next-auth.d.ts +++ b/src/vertex/types/next-auth.d.ts @@ -5,6 +5,7 @@ declare module 'next-auth' { interface Session extends DefaultSession { user?: ({ id: string; + _id?: string; accessToken: string; userName: string; organization: string; @@ -14,11 +15,14 @@ declare module 'next-auth' { country: string; timezone: string; phoneNumber: string; + image?: string; + expiresAt?: string; exp?: number; } & DefaultSession['user']) | null; } interface User extends DefaultUser { + _id?: string; accessToken: string; userName: string; organization: string; @@ -28,12 +32,14 @@ declare module 'next-auth' { country: string; timezone: string; phoneNumber: string; + expiresAt?: string; exp?: number; } } declare module 'next-auth/jwt' { interface JWT extends DefaultJWT { + _id?: string; accessToken: string; userName: string; organization: string; @@ -43,6 +49,8 @@ declare module 'next-auth/jwt' { country: string; timezone: string; phoneNumber: string; + image?: string; + expiresAt?: string; exp?: number; } } From b3947bf1d4645d1e36c5a311bbc545fc31d1ac07 Mon Sep 17 00:00:00 2001 From: Paul Ochieng Levi Date: Tue, 9 Jun 2026 22:37:00 +0300 Subject: [PATCH 2/2] fix: improve logout error handling and fallback sign-out mechanism; enhance user session data assignment --- src/platform/src/shared/hooks/useLogout.ts | 12 +++++++++--- src/vertex/app/api/auth/[...nextauth]/options.ts | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/platform/src/shared/hooks/useLogout.ts b/src/platform/src/shared/hooks/useLogout.ts index 996f93ca2b..5cb4ca75a6 100644 --- a/src/platform/src/shared/hooks/useLogout.ts +++ b/src/platform/src/shared/hooks/useLogout.ts @@ -121,9 +121,15 @@ export const useLogout = (callbackUrl?: string) => { window.location.href = callbackUrl || '/user/login'; } catch (error) { logger.error('Logout error in useLogout', error); - // Reset logging out state on error - dispatch(setLoggingOut(false)); - // Force full page reload to clear all session state + // signOut failed — cookie may still be set. Attempt fallback form-based + // signOut which is more reliable for cookie clearing, then navigate. + try { + await signOut({ redirect: false }); + } catch { + // Both attempts failed — clear state and let the user know + dispatch(setLoggingOut(false)); + return; + } window.location.href = callbackUrl || '/user/login'; } finally { sharedIsLoggingOut = false; diff --git a/src/vertex/app/api/auth/[...nextauth]/options.ts b/src/vertex/app/api/auth/[...nextauth]/options.ts index e5eb3f8706..7dc76c98b7 100644 --- a/src/vertex/app/api/auth/[...nextauth]/options.ts +++ b/src/vertex/app/api/auth/[...nextauth]/options.ts @@ -334,7 +334,7 @@ export const options: NextAuthOptions = { session.user = { ...session.user, id: token.id as string, - _id: token.id as string, + _id: (token._id as string) || (token.id as string), accessToken: token.accessToken as string, userName: token.userName as string, organization: token.organization as string,