// server/module.ts 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 { z } from "zod"; function parseBoolean(value, fallback) { if (value === void 0) { 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; } function createAuthModule(options) { 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 = [ 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, 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) { console.error("[authjs:error]", error.name, error.message, error.cause ?? ""); }, warn(code) { console.warn("[authjs:warn]", code); }, debug(message, metadata) { console.log("[authjs:debug]", message, metadata ?? ""); } } : void 0, session: { strategy: "database" }, secret: options.authSecret ?? process.env.AUTH_SECRET, cookies: { sessionToken: { name: options.sessionCookieName, options: { httpOnly: true, sameSite: "lax", path: "/", secure: options.sessionCookieSecure } } }, providers, pages: { signIn: signInPath }, callbacks: { redirect: async ({ url, baseUrl }) => { const clientOrigin = new URL(options.clientUrl).origin; const successUrl = new URL(authenticatedRedirectPath, clientOrigin).toString(); const shouldForceChat = (pathname, searchParams) => { 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 = 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: "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 <= /* @__PURE__ */ new Date()) { const payload = { 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.authUser = authUser; await options.onSessionValidated?.(authUser); next(); }; const extractSessionToken = (cookieHeader) => { 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))]; }).filter((entry) => 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]; }).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 }; } // server/routes.ts import { createHash, randomBytes } from "crypto"; import { z as z2 } from "zod"; var defaultNormalizeEmail = (email) => email.trim(); var defaultPasswordResetIdentifierPrefix = "password-reset:"; var defaultMessages = { invalidPayload: "Invalid payload", emailAlreadyUsed: "Email already used", accountNotFound: "Account not found", externalAccountOnly: "This account uses an external sign-in provider.", invalidPassword: "Invalid password", passwordResetUnavailable: "Email service is not configured.", invalidResetLink: "Invalid reset link", expiredResetLink: "Invalid or expired reset link" }; function hashPasswordResetToken(token) { return createHash("sha256").update(token).digest("hex"); } function buildPasswordResetIdentifier(prefix, userId) { return `${prefix}${userId}`; } function registerAuthApiRoutes(options) { const authBasePath = options.authBasePath ?? "/auth"; const authApiBasePath = options.authApiBasePath ?? "/api/auth"; const mePath = options.mePath ?? "/api/me"; const normalizeEmail = options.normalizeEmail ?? defaultNormalizeEmail; const passwordHasher = options.passwordHasher ?? ((password) => Promise.resolve(password)); const passwordComparator = options.passwordComparator ?? ((password, hash) => Promise.resolve(password === hash)); const passwordResetIdentifierPrefix = options.passwordReset?.identifierPrefix ?? defaultPasswordResetIdentifierPrefix; const messages = { ...defaultMessages, ...options.messages ?? {} }; const findUserByEmail = async (email) => { const normalized = normalizeEmail(email); const lowered = normalized.toLowerCase(); return options.prisma.user.findFirst({ where: { OR: lowered === normalized ? [{ email: normalized }] : [{ email: normalized }, { email: lowered }] }, select: { id: true, email: true, name: true, image: true, passwordHash: true, emailVerified: true } }); }; const getPasswordResetContext = async (rawToken) => { const verificationToken = await options.prisma.verificationToken.findUnique({ where: { token: hashPasswordResetToken(rawToken) }, select: { identifier: true, expires: true } }); if (!verificationToken || verificationToken.expires <= /* @__PURE__ */ new Date() || !verificationToken.identifier.startsWith(passwordResetIdentifierPrefix)) { return null; } const userId = verificationToken.identifier.slice(passwordResetIdentifierPrefix.length); if (!userId) { return null; } const user = await options.prisma.user.findUnique({ where: { id: userId }, select: { id: true, email: true, name: true, passwordHash: true, emailVerified: true } }); if (!user?.email) { return null; } return { verificationToken, user }; }; options.app.use(authBasePath, options.authHandler); options.app.get(`${authApiBasePath}/providers`, (_req, res) => { res.json(options.providersAvailability); }); options.app.post(`${authApiBasePath}/register`, async (req, res) => { const parsed = z2.object({ name: z2.string().min(2).max(60), email: z2.string().email(), password: z2.string().min(8) }).safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: messages.invalidPayload }); } const email = normalizeEmail(parsed.data.email); const exists = await findUserByEmail(email); if (exists) { return res.status(409).json({ error: messages.emailAlreadyUsed }); } const passwordHash = await passwordHasher(parsed.data.password); const created = await options.prisma.user.create({ data: { name: parsed.data.name, email, passwordHash }, select: { id: true, email: true, name: true } }); await options.onUserRegistered?.(created); return res.status(201).json(created); }); options.app.post(`${authApiBasePath}/login`, async (req, res) => { const parsed = z2.object({ email: z2.string().email(), password: z2.string().min(8) }).safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: messages.invalidPayload }); } const email = normalizeEmail(parsed.data.email); const user = await findUserByEmail(email); if (!user) { return res.status(404).json({ error: messages.accountNotFound }); } if (!user.passwordHash) { return res.status(400).json({ error: messages.externalAccountOnly }); } const valid = await passwordComparator(parsed.data.password, user.passwordHash); if (!valid) { return res.status(401).json({ error: messages.invalidPassword }); } const sessionToken = randomBytes(32).toString("hex"); const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1e3); await options.prisma.session.create({ data: { sessionToken, userId: user.id, expires } }); res.cookie(options.sessionCookieName, sessionToken, { httpOnly: true, sameSite: "lax", secure: options.sessionCookieSecure, path: "/", expires }); return res.status(200).json({ ok: true }); }); options.app.post(`${authApiBasePath}/password-reset/request`, async (req, res) => { const parsed = z2.object({ email: z2.string().email() }).safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: messages.invalidPayload }); } if (!options.passwordReset?.enabled) { return res.status(503).json({ error: messages.passwordResetUnavailable }); } const email = normalizeEmail(parsed.data.email); const user = await findUserByEmail(email); if (!user?.email) { return res.status(200).json({ ok: true }); } const rawToken = randomBytes(32).toString("hex"); const identifier = buildPasswordResetIdentifier(passwordResetIdentifierPrefix, user.id); const expiresAt = new Date(Date.now() + (options.passwordReset.tokenTtlMs ?? 2 * 60 * 60 * 1e3)); const resetUrl = options.passwordReset.buildResetUrl(rawToken); const isPasswordCreation = !user.passwordHash; await options.prisma.verificationToken.deleteMany({ where: { OR: [{ identifier }, { expires: { lt: /* @__PURE__ */ new Date() } }] } }); await options.prisma.verificationToken.create({ data: { identifier, token: hashPasswordResetToken(rawToken), expires: expiresAt } }); await options.passwordReset.sendMessage({ user: { id: user.id, email: user.email, name: user.name, passwordHash: user.passwordHash }, resetUrl, isPasswordCreation, expiresAt }); return res.status(200).json({ ok: true }); }); options.app.get(`${authApiBasePath}/password-reset/validate`, async (req, res) => { if (!options.passwordReset?.enabled) { return res.status(400).json({ error: messages.expiredResetLink }); } const parsed = z2.object({ token: z2.string().min(1) }).safeParse({ token: Array.isArray(req.query.token) ? req.query.token[0] : req.query.token }); if (!parsed.success) { return res.status(400).json({ error: messages.invalidResetLink }); } const context = await getPasswordResetContext(parsed.data.token); if (!context) { return res.status(400).json({ error: messages.expiredResetLink }); } return res.status(200).json({ ok: true, email: context.user.email, mode: context.user.passwordHash ? "reset" : "create" }); }); options.app.post(`${authApiBasePath}/password-reset/confirm`, async (req, res) => { if (!options.passwordReset?.enabled) { return res.status(400).json({ error: messages.expiredResetLink }); } const parsed = z2.object({ token: z2.string().min(1), password: z2.string().min(8) }).safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: messages.invalidPayload }); } const context = await getPasswordResetContext(parsed.data.token); if (!context) { return res.status(400).json({ error: messages.expiredResetLink }); } const passwordHash = await passwordHasher(parsed.data.password); await options.prisma.$transaction([ options.prisma.verificationToken.deleteMany({ where: { identifier: context.verificationToken.identifier } }), options.prisma.session.deleteMany({ where: { userId: context.user.id } }), options.prisma.user.update({ where: { id: context.user.id }, data: { passwordHash, emailVerified: context.user.emailVerified ?? /* @__PURE__ */ new Date() } }) ]); await options.onPasswordResetConfirmed?.(context.user); return res.status(200).json({ ok: true }); }); options.app.post(`${authApiBasePath}/logout`, async (req, res) => { const token = options.extractSessionToken(req.headers.cookie); if (token) { await options.prisma.session.deleteMany({ where: { sessionToken: token } }); } const cookieNamesToClear = [ options.sessionCookieName, ...options.extraCookieNamesToClear ?? [], "authjs.session-token", "__Secure-authjs.session-token", "next-auth.session-token", "__Secure-next-auth.session-token" ]; for (const cookieName of cookieNamesToClear) { res.clearCookie(cookieName, { path: "/" }); } return res.status(200).json({ ok: true }); }); options.app.get(mePath, options.requireSession, async (req, res) => { res.json({ user: req.authUser }); }); } export { createAuthModule, registerAuthApiRoutes }; //# sourceMappingURL=index.js.map