import NextAuth, { Session } from 'next-auth'; import Credentials from 'next-auth/providers/credentials'; import { ROUTES } from 'routes'; import { URLS, user } from '../data/urls'; import Settings from 'settings'; import { urlLocaleMatcherRegex } from '../utils'; import logger from '@akinon/next/utils/log'; import { AuthError } from '../types'; import getRootHostname from '../utils/get-root-hostname'; import { LocaleUrlStrategy } from '../localization'; import { cookies, headers } from 'next/headers'; // Shared helper async function getCurrentUser(sessionId: string, currency = '') { const reqHeaders = { 'Content-Type': 'application/json', Cookie: `osessionid=${sessionId}`, 'x-currency': currency }; const currentUser = await ( await fetch(URLS.user.currentUser, { headers: reqHeaders }) ).json(); logger.debug('Successfully fetched current user data', { currentUser }); return { pk: currentUser.pk, firstName: currentUser.first_name, lastName: currentUser.last_name, email: currentUser.email, phone: currentUser.phone, emailAllowed: currentUser.email_allowed, smsAllowed: currentUser.sms_allowed, dateJoined: currentUser.date_joined, lastLogin: currentUser.last_login, dateOfBirth: currentUser.date_of_birth, hashedEmail: currentUser.hashed_email, gender: currentUser.gender, token: '' }; } // Runtime-detected error throwing: uses CredentialsSignin on v5, plain Error on v4 function throwAuthError(errors: AuthError[]): never { const nextAuthModule = require('next-auth'); if (nextAuthModule.CredentialsSignin) { class PzCredentialsError extends nextAuthModule.CredentialsSignin { code = 'credentials'; constructor(errs: AuthError[]) { super(); this.code = JSON.stringify(errs); } } throw new PzCredentialsError(errors); } throw new Error(JSON.stringify(errors)); } // Shared credential config used by both v4 and v5 error handling function buildFieldErrors(response: any) { const errors = [] as AuthError[]; const fieldErrors = Object.keys(response ?? {}) .filter((key) => key !== 'non_field_errors') .map((key) => ({ name: key, value: response[key] })); if (response.redirect_url?.includes('captcha')) { errors.push({ type: 'captcha' }); } else if (fieldErrors.length) { errors.push({ type: 'field_errors', data: fieldErrors }); } else if (response.non_field_errors) { errors.push({ type: 'non_field_errors', data: response.non_field_errors }); } return errors; } // ============================================================ // v5 (App Router / next-auth v5) — named export for new brands // ============================================================ const getDefaultAuthConfig = () => { return { providers: [ Credentials({ id: 'oauth', name: 'credentials', credentials: {}, authorize: async (credentials: any) => { const cookieStore = await cookies(); const sessionId = cookieStore.get('osessionid')?.value; if (!sessionId) { return null; } const currentUser = await getCurrentUser( sessionId, cookieStore.get('pz-currency')?.value ?? '' ); return currentUser; } }), Credentials({ id: 'default', name: 'credentials', credentials: { email: { label: 'Email', type: 'email', placeholder: 'Email' }, password: { label: 'Password', type: 'password', placeholder: 'Password' }, formType: {}, locale: {}, captchaValidated: {} }, authorize: async (credentials: any) => { const cookieStore = await cookies(); const headerStore = await headers(); const reqHeaders: HeadersInit = new Headers(); const language = Settings.localization.locales.find( (item: any) => item.value === credentials.locale ).apiValue; const userIp = headerStore.get('x-forwarded-for') ?? ''; reqHeaders.set('Content-Type', 'application/json'); reqHeaders.set('cookie', headerStore.get('cookie') ?? ''); reqHeaders.set('Accept-Language', `${language}`); reqHeaders.set( 'x-currency', cookieStore.get('pz-currency')?.value ?? '' ); reqHeaders.set('x-forwarded-for', userIp); reqHeaders.set( 'x-app-device', headerStore.get('x-app-device') ?? '' ); reqHeaders.set( 'x-frontend-id', cookieStore.get('pz-frontend-id')?.value || '' ); logger.debug('Trying to login/register', { formType: credentials.formType, userIp }); const existingSessionId = cookieStore.get('osessionid')?.value; if (existingSessionId) { const checkCurrentUser = await getCurrentUser( existingSessionId, cookieStore.get('pz-currency')?.value ?? '' ); if (checkCurrentUser?.pk) { const sessionCookie = reqHeaders .get('cookie') ?.match(/osessionid=\w+/)?.[0] .replace(/osessionid=/, ''); if (sessionCookie) { reqHeaders.set('cookie', sessionCookie); } } else { // Stale session cookie — remove from headers and clear in browser const currentCookies = reqHeaders.get('cookie') || ''; const cleanedCookies = currentCookies .split(';') .filter((c) => !c.trim().startsWith('osessionid=')) .join(';') .trim(); reqHeaders.set('cookie', cleanedCookies); const { localeUrlStrategy } = Settings.localization; const fallbackHost = headerStore.get('x-forwarded-host') || headerStore.get('host'); const hostname = process.env.NEXT_PUBLIC_URL || `https://${fallbackHost}`; const rootHostname = localeUrlStrategy === LocaleUrlStrategy.Subdomain ? getRootHostname(hostname) : null; const expireOptions = { path: '/', maxAge: 0, ...(rootHostname ? { domain: rootHostname } : {}) }; cookieStore.set('osessionid', '', expireOptions); cookieStore.set('sessionid', '', expireOptions); } } const apiRequest = await fetch( `${Settings.commerceUrl}${user[credentials.formType]}`, { method: 'POST', headers: reqHeaders, body: JSON.stringify(credentials) } ); logger.info(`Login/Register request result: ${apiRequest.status}`, { userIp }); const response = (await apiRequest.json()) as { key: string; non_field_errors: string[]; redirect_url: string; }; logger.debug(`Login/Register response: ${JSON.stringify(response)}`); let sessionId = ''; const setCookieHeader = apiRequest.headers.get('set-cookie'); if (setCookieHeader) { sessionId = setCookieHeader .match(/osessionid=\w+/)?.[0] .replace(/osessionid=/, '') || ''; logger.debug(`Login/Register session id: ${sessionId}`); } else { logger.warn('No set-cookie header found in response'); } if (sessionId) { const maxAge = 30 * 24 * 60 * 60; const { localeUrlStrategy } = Settings.localization; const fallbackHost = headerStore.get('x-forwarded-host') || headerStore.get('host'); const hostname = process.env.NEXT_PUBLIC_URL || `https://${fallbackHost}`; const rootHostname = localeUrlStrategy === LocaleUrlStrategy.Subdomain ? getRootHostname(hostname) : null; const cookieOptions = { path: '/', httpOnly: true, secure: true, maxAge, ...(rootHostname ? { domain: rootHostname } : {}) }; cookieStore.set('osessionid', sessionId, cookieOptions); cookieStore.set('sessionid', sessionId, cookieOptions); } if (!response.key) { const errors = buildFieldErrors(response); if (apiRequest.status === 202) { errors.push({ type: 'otp' }); } if (errors.length) { throwAuthError(errors); } } const currentUser = await getCurrentUser( sessionId, cookieStore.get('pz-currency')?.value ?? '' ); return currentUser; } }) ], callbacks: { jwt: async ({ token, user }: any) => { if (user) { token.user = user; } return token; }, async session({ session, token }: any) { session.user = token.user as any; return session; }, async redirect({ url, baseUrl }: { url: string; baseUrl: string }) { const headerStore = await headers(); const pathname = url.startsWith('/') ? url : url.replace(baseUrl, ''); const pathnameWithoutLocale = pathname.replace( urlLocaleMatcherRegex, '' ); const localeResults = headerStore .get('referer') ?.replace(baseUrl, '') ?.match(urlLocaleMatcherRegex); return `${baseUrl}${localeResults?.[0] || ''}${pathnameWithoutLocale}`; } }, events: { signIn: () => { logger.debug('Successfully signed in'); }, signOut: async () => { const cookieStore = await cookies(); cookieStore.set('osessionid', '', { path: '/', maxAge: 0 }); cookieStore.set('sessionid', '', { path: '/', maxAge: 0 }); logger.debug('Successfully signed out'); } }, pages: { signIn: ROUTES.AUTH, error: ROUTES.AUTH } }; }; export const createAuth = (customOptions?: () => Partial) => { const baseConfig = getDefaultAuthConfig(); const customConfig = customOptions ? customOptions() : {}; const mergedConfig = { trustHost: true, ...baseConfig, ...customConfig, providers: [ ...baseConfig.providers, ...(customConfig.providers || []) ], callbacks: { ...baseConfig.callbacks, ...customConfig.callbacks }, events: { ...baseConfig.events, ...customConfig.events }, pages: { ...baseConfig.pages, ...customConfig.pages } }; return (NextAuth as any)(mergedConfig); }; // ============================================================ // v4 (Pages Router / next-auth v4) — default export for backward compat // ============================================================ const defaultNextAuthOptionsV4 = (req: any, res: any) => { return { providers: [ Credentials({ id: 'oauth', name: 'credentials', credentials: {}, authorize: async () => { const sessionId = req.cookies['osessionid']; if (!sessionId) { return null; } const currentUser = await getCurrentUser( sessionId, req.cookies['pz-currency'] ?? '' ); return currentUser; } }), Credentials({ id: 'default', name: 'credentials', credentials: { email: { label: 'Email', type: 'email', placeholder: 'Email' }, password: { label: 'Password', type: 'password', placeholder: 'Password' }, formType: {}, locale: {}, captchaValidated: {} }, authorize: async (credentials: any) => { const reqHeaders: HeadersInit = new Headers(); const language = Settings.localization.locales.find( (item: any) => item.value === credentials.locale ).apiValue; const userIp = req.headers['x-forwarded-for']?.toString() ?? ''; reqHeaders.set('Content-Type', 'application/json'); reqHeaders.set('cookie', `${req.headers.cookie}`); reqHeaders.set('Accept-Language', `${language}`); reqHeaders.set('x-currency', req.cookies['pz-currency'] ?? ''); reqHeaders.set('x-forwarded-for', userIp); reqHeaders.set( 'x-app-device', req.headers['x-app-device']?.toString() ?? '' ); reqHeaders.set('x-frontend-id', req.cookies['pz-frontend-id'] || ''); logger.debug('Trying to login/register', { formType: credentials.formType, userIp }); if (req.cookies['osessionid']) { const checkCurrentUser = await getCurrentUser( req.cookies['osessionid'], req.cookies['pz-currency'] ?? '' ); if (checkCurrentUser?.pk) { const sessionCookie = reqHeaders .get('cookie') ?.match(/osessionid=\w+/)?.[0] .replace(/osessionid=/, ''); if (sessionCookie) { reqHeaders.set('cookie', sessionCookie); } } else { // Stale session cookie — remove from headers and clear in browser const currentCookies = reqHeaders.get('cookie') || ''; const cleanedCookies = currentCookies .split(';') .filter((c) => !c.trim().startsWith('osessionid=')) .join(';') .trim(); reqHeaders.set('cookie', cleanedCookies); const { localeUrlStrategy } = Settings.localization; const fallbackHost = req.headers['x-forwarded-host']?.toString() || req.headers.host?.toString(); const hostname = process.env.NEXT_PUBLIC_URL || `https://${fallbackHost}`; const rootHostname = localeUrlStrategy === LocaleUrlStrategy.Subdomain ? getRootHostname(hostname) : null; const domainOption = rootHostname ? ` Domain=${rootHostname};` : ''; res.setHeader('Set-Cookie', [ `osessionid=; Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT;${domainOption}`, `sessionid=; Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT;${domainOption}` ]); } } const apiRequest = await fetch( `${Settings.commerceUrl}${user[credentials.formType]}`, { method: 'POST', headers: reqHeaders, body: JSON.stringify(credentials) } ); logger.info(`Login/Register request result: ${apiRequest.status}`, { userIp }); const response = (await apiRequest.json()) as { key: string; non_field_errors: string[]; redirect_url: string; }; logger.debug(`Login/Register response: ${JSON.stringify(response)}`); let sessionId = ''; const setCookieHeader = apiRequest.headers.get('set-cookie'); if (setCookieHeader) { sessionId = setCookieHeader .match(/osessionid=\w+/)?.[0] .replace(/osessionid=/, '') || ''; logger.debug(`Login/Register session id: ${sessionId}`); } else { logger.warn('No set-cookie header found in response'); } if (sessionId) { const maxAge = 30 * 24 * 60 * 60; const { localeUrlStrategy } = Settings.localization; const fallbackHost = req.headers['x-forwarded-host']?.toString() || req.headers.host?.toString(); const hostname = process.env.NEXT_PUBLIC_URL || `https://${fallbackHost}`; const rootHostname = localeUrlStrategy === LocaleUrlStrategy.Subdomain ? getRootHostname(hostname) : null; const domainOption = rootHostname ? `Domain=${rootHostname};` : ''; const cookieOptions = `Path=/; HttpOnly; Secure; Max-Age=${maxAge}; ${domainOption}`; res.setHeader('Set-Cookie', [ `osessionid=${sessionId}; ${cookieOptions}`, `sessionid=${sessionId}; ${cookieOptions}` ]); } if (!response.key) { const errors = buildFieldErrors(response); if (apiRequest.status === 202) { errors.push({ type: 'otp' }); } if (errors.length) { throwAuthError(errors); } } const currentUser = await getCurrentUser( sessionId, req.cookies['pz-currency'] ?? '' ); return currentUser; } }) ], callbacks: { jwt: async ({ token, user }: any) => { if (user) { token.user = user; } return token; }, async session({ session, user, token }: any) { session.user = token.user as Session['user']; return session; }, async redirect({ url, baseUrl }: { url: string; baseUrl: string }) { const pathname = url.startsWith('/') ? url : url.replace(baseUrl, ''); const pathnameWithoutLocale = pathname.replace( urlLocaleMatcherRegex, '' ); const localeResults = req.headers.referer ?.replace(baseUrl, '') ?.match(urlLocaleMatcherRegex); return `${baseUrl}${localeResults?.[0] || ''}${pathnameWithoutLocale}`; } }, events: { signIn: () => { logger.debug('Successfully signed in'); }, signOut: () => { res.setHeader('Set-Cookie', [ `osessionid=; Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`, `sessionid=; Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT` ]); logger.debug('Successfully signed out'); } }, pages: { signIn: ROUTES.AUTH, error: ROUTES.AUTH } }; }; const Auth = ( req: any, res: any, customOptions?: (req: any, res: any) => Partial ) => { const baseOptions = defaultNextAuthOptionsV4(req, res); const customOptionsResult = customOptions ? customOptions(req, res) : {}; const mergedOptions = { ...baseOptions, ...customOptionsResult, providers: [ ...baseOptions.providers, ...(customOptionsResult.providers || []) ], callbacks: { ...baseOptions.callbacks, ...customOptionsResult.callbacks }, events: { ...baseOptions.events, ...customOptionsResult.events }, pages: { ...baseOptions.pages, ...customOptionsResult.pages } }; return (NextAuth as any)(req, res, mergedOptions); }; export default Auth;