import { ExpressAuth } from "@auth/express"; import Credentials from "@auth/express/providers/credentials"; import Google from "@auth/express/providers/google"; import Slack from "@auth/express/providers/slack"; import { PrismaAdapter } from "@auth/prisma-adapter"; import type { Request, RequestHandler } from "express"; import { z } from "zod"; type CredentialUser = { id: string; name: string | null; email: string | null; image?: string | null; passwordHash: string | null; }; type CreateAuthModuleOptions = { prisma: any; clientUrl: string; sessionCookieName: string; sessionCookieSecure: boolean; authUrl?: string; authDebug?: boolean; authSecret?: string; trustHost?: boolean; extraSessionCookieNames?: string[]; signInPath?: string; authenticatedRedirectPath?: string; googleClientId?: string; googleClientSecret?: string; slackClientId?: string; slackClientSecret?: string; findCredentialsUserByEmail: (email: string) => Promise; comparePassword: (password: string, passwordHash: string) => Promise; sessionUserSelect: Record; mapSessionUser: (user: any) => TAuthUser; onSessionValidated?: (user: TAuthUser) => Promise | void; }; function parseBoolean(value: string | undefined, fallback: boolean): boolean { if (value === undefined) { return fallback; } const normalized = value.trim().toLowerCase(); if (["1", "true", "yes", "on"].includes(normalized)) { return true; } if (["0", "false", "no", "off"].includes(normalized)) { return false; } return fallback; } export function createAuthModule(options: CreateAuthModuleOptions) { const signInPath = options.signInPath ?? "/login"; const authenticatedRedirectPath = options.authenticatedRedirectPath ?? "/chat"; const googleAuthEnabled = Boolean(options.googleClientId && options.googleClientSecret); const slackAuthEnabled = Boolean(options.slackClientId && options.slackClientSecret); const providers: any[] = [ Credentials({ name: "Email et mot de passe", credentials: { email: { label: "Email", type: "email" }, password: { label: "Password", type: "password" } }, authorize: async (rawCredentials) => { const parsed = z .object({ email: z.string().email(), password: z.string().min(8) }) .safeParse(rawCredentials); if (!parsed.success) { return null; } const user = await options.findCredentialsUserByEmail(parsed.data.email); if (!user?.passwordHash) { return null; } const valid = await options.comparePassword(parsed.data.password, user.passwordHash); if (!valid) { return null; } return { id: user.id, name: user.name, email: user.email, image: user.image ?? null }; } }) ]; if (googleAuthEnabled) { providers.push( Google({ clientId: options.googleClientId!, clientSecret: options.googleClientSecret!, authorization: { params: { prompt: "select_account" } }, allowDangerousEmailAccountLinking: true }) ); } if (slackAuthEnabled) { providers.push( Slack({ clientId: options.slackClientId!, clientSecret: options.slackClientSecret!, allowDangerousEmailAccountLinking: true }) ); } const authConfig = { adapter: PrismaAdapter(options.prisma), trustHost: options.trustHost ?? parseBoolean(process.env.AUTH_TRUST_HOST, true), debug: options.authDebug ?? parseBoolean(process.env.AUTH_DEBUG, false), logger: options.authDebug ? { error(error: Error) { console.error("[authjs:error]", error.name, error.message, error.cause ?? ""); }, warn(code: string) { console.warn("[authjs:warn]", code); }, debug(message: string, metadata?: unknown) { console.log("[authjs:debug]", message, metadata ?? ""); } } : undefined, session: { strategy: "database" as const }, secret: options.authSecret ?? process.env.AUTH_SECRET, cookies: { sessionToken: { name: options.sessionCookieName, options: { httpOnly: true, sameSite: "lax" as const, path: "/", secure: options.sessionCookieSecure } } }, providers, pages: { signIn: signInPath }, callbacks: { redirect: async ({ url, baseUrl }: { url: string; baseUrl: string }) => { const clientOrigin = new URL(options.clientUrl).origin; const successUrl = new URL(authenticatedRedirectPath, clientOrigin).toString(); const shouldForceChat = (pathname: string, searchParams: URLSearchParams): boolean => { if (pathname !== "/" && pathname !== signInPath) { return false; } return !searchParams.has("error"); }; if (url.startsWith("/")) { const relative = new URL(url, clientOrigin); if (shouldForceChat(relative.pathname, relative.searchParams)) { return successUrl; } return `${clientOrigin}${relative.pathname}${relative.search}${relative.hash}`; } try { const target = new URL(url); const base = new URL(baseUrl); if (target.origin === clientOrigin) { if (shouldForceChat(target.pathname, target.searchParams)) { return successUrl; } return target.toString(); } if (target.origin === base.origin) { if (shouldForceChat(target.pathname, target.searchParams)) { return successUrl; } return `${clientOrigin}${target.pathname}${target.search}${target.hash}`; } } catch { return successUrl; } return successUrl; } } }; const authHandler = ExpressAuth(authConfig); const requireSession: RequestHandler = async (req, res, next) => { const token = extractSessionToken(req.headers.cookie); const authDebug = options.authDebug ?? parseBoolean(process.env.AUTH_DEBUG, false); if (!token) { const payload: { error: string; reason?: string } = { error: "Unauthorized" }; if (authDebug) { payload.reason = "missing_session_cookie"; } return res.status(401).json(payload); } const session = await options.prisma.session.findUnique({ where: { sessionToken: token }, include: { user: { select: options.sessionUserSelect } } }); if (!session || session.expires <= new Date()) { const payload: { error: string; reason?: string } = { error: "Unauthorized" }; if (authDebug) { payload.reason = !session ? "session_not_found" : "session_expired"; } return res.status(401).json(payload); } const authUser = options.mapSessionUser(session.user); (req as Request & { authUser?: TAuthUser }).authUser = authUser; await options.onSessionValidated?.(authUser); next(); }; const extractSessionToken = (cookieHeader: string | undefined): string | null => { if (!cookieHeader) { return null; } const cookies = cookieHeader .split(";") .map((part) => part.trim()) .map((part) => { const index = part.indexOf("="); if (index < 0) { return null; } return [part.slice(0, index), decodeURIComponent(part.slice(index + 1))] as const; }) .filter((entry): entry is readonly [string, string] => entry !== null); const possibleNames = [ options.sessionCookieName, ...(options.extraSessionCookieNames ?? []), "__Secure-authjs.session-token", "authjs.session-token", "__Secure-next-auth.session-token", "next-auth.session-token" ]; for (const name of possibleNames) { const exact = cookies.find(([cookieName]) => cookieName === name); if (exact) { return exact[1]; } const chunks = cookies .filter(([cookieName]) => cookieName.startsWith(`${name}.`)) .map(([cookieName, value]) => { const suffix = cookieName.slice(name.length + 1); return [Number.parseInt(suffix, 10), value] as const; }) .filter(([index]) => Number.isInteger(index)) .sort((left, right) => left[0] - right[0]); if (chunks.length > 0) { return chunks.map(([, value]) => value).join(""); } } return null; }; return { authConfig, authHandler, requireSession, extractSessionToken, googleAuthEnabled, slackAuthEnabled }; }