243 lines
6.5 KiB
TypeScript
243 lines
6.5 KiB
TypeScript
// 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";
|
|
|
|
type ApiEnvelope<T> = {
|
|
data?: ApiEnvelope<T> | T;
|
|
message?: string;
|
|
statusCode?: number;
|
|
};
|
|
|
|
const authResponseSchema = z.object({
|
|
access_token: z.string().min(1),
|
|
refresh_token: z.string().min(1).optional(),
|
|
user: z
|
|
.object({
|
|
user_id: z.number(),
|
|
username: z.string().min(1),
|
|
email: z.string(),
|
|
firstName: z.string().min(1),
|
|
lastName: z.string().min(1),
|
|
role: z.string().min(1).optional(),
|
|
primaryOrganizationId: z.number().nullable().optional(),
|
|
})
|
|
.optional(),
|
|
});
|
|
|
|
// Schema for input validation
|
|
const loginSchema = z.object({
|
|
username: z.string().min(1),
|
|
password: z.string().min(1),
|
|
});
|
|
|
|
// ✅ ใช้แบบ SSR-safe (ดีที่สุด)
|
|
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;
|
|
} catch {
|
|
return Date.now();
|
|
}
|
|
}
|
|
|
|
function unwrapApiData<T>(payload: ApiEnvelope<T> | T): ApiEnvelope<T> | T | null {
|
|
let current: ApiEnvelope<T> | T | null = payload;
|
|
|
|
while (
|
|
current &&
|
|
typeof current === "object" &&
|
|
"data" in current &&
|
|
current.data !== undefined
|
|
) {
|
|
current = current.data;
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
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 parsedAuthResponse = authResponseSchema.safeParse(
|
|
unwrapApiData(refreshedTokens)
|
|
);
|
|
|
|
if (!parsedAuthResponse.success) {
|
|
throw new Error("Invalid refresh token response");
|
|
}
|
|
|
|
return {
|
|
...token,
|
|
accessToken: parsedAuthResponse.data.access_token,
|
|
accessTokenExpires: getJwtExpiry(parsedAuthResponse.data.access_token),
|
|
refreshToken:
|
|
parsedAuthResponse.data.refresh_token ?? token.refreshToken,
|
|
};
|
|
} catch {
|
|
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 {
|
|
const payload = loginSchema.parse({
|
|
username: credentials.username,
|
|
password: credentials.password,
|
|
});
|
|
|
|
console.log(`[AUTH] Attempting login at: ${baseUrl}/auth/login`);
|
|
console.log(`[AUTH] INTERNAL_API_URL: ${process.env.INTERNAL_API_URL}`);
|
|
console.log(`[AUTH] 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",
|
|
});
|
|
|
|
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 responseJson = await res.json();
|
|
console.log("[AUTH] Backend raw response:", JSON.stringify(responseJson));
|
|
|
|
const parsedAuthResponse = authResponseSchema.safeParse(
|
|
unwrapApiData(responseJson)
|
|
);
|
|
|
|
if (!parsedAuthResponse.success || !parsedAuthResponse.data.user) {
|
|
console.error(
|
|
"[AUTH] Invalid backend response:",
|
|
unwrapApiData(responseJson)
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const backendData = parsedAuthResponse.data;
|
|
const user = backendData.user;
|
|
|
|
if (!user) {
|
|
console.error("[AUTH] Invalid backend response:", backendData);
|
|
return null;
|
|
}
|
|
|
|
console.log(
|
|
`[AUTH] Login Successful for user: ${
|
|
user.username || "unknown"
|
|
}`
|
|
);
|
|
|
|
return {
|
|
id: user.user_id.toString(),
|
|
name: `${user.firstName} ${user.lastName}`,
|
|
email: user.email,
|
|
username: user.username,
|
|
role: user.role || "User",
|
|
organizationId: user.primaryOrganizationId,
|
|
accessToken: backendData.access_token,
|
|
refreshToken: backendData.refresh_token,
|
|
} as User;
|
|
} catch (error) {
|
|
console.error("[AUTH] Network Error:", error);
|
|
return null;
|
|
}
|
|
},
|
|
}),
|
|
],
|
|
pages: {
|
|
signIn: "/login",
|
|
error: "/login",
|
|
},
|
|
callbacks: {
|
|
async jwt({ token, user }) {
|
|
if (user) {
|
|
return {
|
|
...token,
|
|
id: user.id,
|
|
username: user.username,
|
|
role: user.role,
|
|
organizationId: user.organizationId,
|
|
accessToken: user.accessToken,
|
|
refreshToken: user.refreshToken,
|
|
accessTokenExpires: getJwtExpiry(user.accessToken!),
|
|
};
|
|
}
|
|
|
|
if (Date.now() < (token.accessTokenExpires as number) - 10000) {
|
|
return token;
|
|
}
|
|
|
|
if (token.error) {
|
|
return token;
|
|
}
|
|
|
|
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;
|
|
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,
|
|
},
|
|
secret: process.env.AUTH_SECRET,
|
|
debug: process.env.NODE_ENV === "development",
|
|
});
|