// File: lib/auth.ts import NextAuth from "next-auth"; import Credentials from "next-auth/providers/credentials"; import { z } from "zod"; import type { User } from "next-auth"; import type { JWT } from "next-auth/jwt"; // Schema for input validation const loginSchema = z.object({ username: z.string().min(1), password: z.string().min(1), }); const baseUrl = (typeof window === "undefined" ? process.env.INTERNAL_API_URL : null) || process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api"; // Helper to parse JWT expiry function getJwtExpiry(token: string): number { try { const payload = JSON.parse(atob(token.split('.')[1])); return payload.exp * 1000; // Convert to ms } catch { return Date.now(); // If invalid, treat as expired } } interface TokenPayload { access_token: string; refresh_token?: string; } interface LoginPayload extends TokenPayload { user: { user_id: number; username: string; email?: string; firstName?: string; lastName?: string; role?: string; primaryOrganizationId?: number; }; } function unwrapApiResponse(value: unknown): unknown { let current = value; for (let i = 0; i < 5; i += 1) { if (!current || typeof current !== "object") { return current; } const record = current as Record; if (typeof record.access_token === "string") { return current; } if (!("data" in record)) { return current; } current = record.data; } return current; } function isTokenPayload(value: unknown): value is TokenPayload { return !!value && typeof value === "object" && typeof (value as Record).access_token === "string"; } function isLoginPayload(value: unknown): value is LoginPayload { if (!isTokenPayload(value)) { return false; } const user = (value as unknown as { user?: unknown }).user; return !!user && typeof user === "object" && typeof (user as Record).username === "string"; } async function refreshAccessToken(token: JWT) { try { const response = await fetch(`${baseUrl}/auth/refresh`, { method: "POST", headers: { Authorization: `Bearer ${token.refreshToken}`, }, }); const refreshedTokens = await response.json(); if (!response.ok) { throw refreshedTokens; } const data = unwrapApiResponse(refreshedTokens); if (!isTokenPayload(data)) { throw new Error("Invalid refresh response format"); } return { ...token, accessToken: data.access_token, accessTokenExpires: getJwtExpiry(data.access_token), refreshToken: data.refresh_token ?? token.refreshToken, }; } catch (error) { // RefreshAccessTokenError - token will be invalidated return { ...token, error: "RefreshAccessTokenError", }; } } export const { handlers: { GET, POST }, auth, signIn, signOut, } = NextAuth({ providers: [ Credentials({ name: "Credentials", credentials: { username: { label: "Username", type: "text" }, password: { label: "Password", type: "password" }, }, authorize: async (credentials) => { if (!credentials?.username || !credentials?.password) return null; try { // 1. Sanitize payload (Only send username and password) const payload = { username: credentials.username as string, password: credentials.password as string, }; console.log(`[AUTH] Attempting login at: ${baseUrl}/auth/login`); console.log(`[AUTH] Current process.env.INTERNAL_API_URL: ${process.env.INTERNAL_API_URL}`); console.log(`[AUTH] Current process.env.NEXT_PUBLIC_API_URL: ${process.env.NEXT_PUBLIC_API_URL}`); const res = await fetch(`${baseUrl}/auth/login`, { method: "POST", body: JSON.stringify(payload), headers: { "Content-Type": "application/json", }, cache: 'no-store', // Disable caching for auth requests }); if (!res.ok) { console.error(`[AUTH] Login Failed: status ${res.status}`); const errorBody = await res.text().catch(() => "No error body"); console.error(`[AUTH] Error details: ${errorBody}`); return null; } const data = await res.json(); const backendData = unwrapApiResponse(data); if (!isLoginPayload(backendData)) { console.error("[AUTH] Login failed: Invalid response format from backend (missing access_token)"); return null; } console.log(`[AUTH] Login Successful for user: ${backendData.user?.username || 'unknown'}`); return { id: backendData.user.user_id.toString(), name: `${backendData.user.firstName ?? ""} ${backendData.user.lastName ?? ""}`.trim(), email: backendData.user.email, username: backendData.user.username, role: backendData.user.role || "User", organizationId: backendData.user.primaryOrganizationId, accessToken: backendData.access_token, refreshToken: backendData.refresh_token, } as User; } catch (error) { console.error("[AUTH] Network/Fetch Error during authorize:", error); return null; } }, }), ], pages: { signIn: "/login", error: "/login", }, callbacks: { async jwt({ token, user }) { if (user) { return { ...token, id: user.id, username: user.username, // ✅ Save username role: user.role, organizationId: user.organizationId, accessToken: user.accessToken, refreshToken: user.refreshToken, accessTokenExpires: getJwtExpiry(user.accessToken!), }; } // Return previous token if valid (minus 10s buffer) if (Date.now() < (token.accessTokenExpires as number) - 10000) { return token; } // If existing token has an error, do not retry refresh (prevents infinite loop) if (token.error) { return token; } // Token expired, refresh it return refreshAccessToken(token); }, async session({ session, token }) { if (token && session.user) { session.user.id = token.id as string; session.user.username = token.username as string; // ✅ Restore username session.user.role = token.role as string; session.user.organizationId = token.organizationId as number; session.accessToken = token.accessToken as string; session.refreshToken = token.refreshToken as string; session.error = token.error as string; } return session; }, }, session: { strategy: "jwt", maxAge: 24 * 60 * 60, // 24 hours }, secret: process.env.AUTH_SECRET, debug: process.env.NODE_ENV === "development", });