import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const"; import { ForbiddenError } from "@shared/_core/errors"; import { SignJWT, jwtVerify } from "jose"; import * as crypto from "node:crypto"; import { promisify } from "node:util"; import { parse as parseCookieHeader } from "cookie"; import type { Request, Response } from "express"; import type { User } from "../../drizzle/schema"; import * as db from "../db"; import { ENV } from "./env"; import { getSessionCookieOptions } from "./cookies"; import { canSendOperationalEmails, sendOperationalEmail } from "../mailer"; const scryptAsync = promisify(crypto.scrypt); const argon2Async = typeof (crypto as any).argon2 === "function" ? promisify((crypto as any).argon2) : null; const KEY_LENGTH = 64; const LOGIN_FAILURE_LIMIT = 5; const LOGIN_LOCK_DURATION_MS = 15 * 60 * 1000; const MFA_CODE_TTL_MS = 10 * 60 * 1000; const MFA_CODE_ATTEMPT_LIMIT = 5; const RETENTION_REPORT_SETTING_KEY = "retention.lastReport"; const SYSTEM_ACTOR_USER_ID = 0; const INTERNAL_MFA_POLICY = { super_admin: { required: true, preferredMethod: "authenticator_app", enforcedMethod: "authenticator_app", emailFallbackAllowed: false, phaseLabel: "Phase 2 cible", }, admin: { required: true, preferredMethod: "authenticator_app", enforcedMethod: "authenticator_app", emailFallbackAllowed: false, phaseLabel: "Phase 2 active", }, directrice: { required: true, preferredMethod: "authenticator_app", enforcedMethod: "authenticator_app", emailFallbackAllowed: false, phaseLabel: "Phase 2 active", }, accueil: { required: true, preferredMethod: "authenticator_app", enforcedMethod: null, emailFallbackAllowed: true, phaseLabel: "Phase 1 active", }, service_terrain: { required: true, preferredMethod: "authenticator_app", enforcedMethod: null, emailFallbackAllowed: true, phaseLabel: "Phase 1 active", }, logistique_controle: { required: true, preferredMethod: "authenticator_app", enforcedMethod: null, emailFallbackAllowed: true, phaseLabel: "Phase 1 active", }, } as const; class AccountLockedError extends Error { constructor(public lockedUntil: Date) { super(`Compte temporairement verrouillé jusqu'au ${lockedUntil.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit", })}.`); } } class MfaChallengeRequiredError extends Error { constructor( public challengeType: "email" | "totp", public challengeToken: string, public expiresAt: Date, public maskedEmail?: string, ) { super("Code de verification requis"); } } type JwtPayload = { userId: number; openId: string; }; type BootstrapUserConfig = { email: string; password: string; name?: string; role?: User["role"]; }; function createBootstrapUser(config: BootstrapUserConfig, index: number): User { const email = normalizeEmail(config.email); const now = new Date(); return { id: index + 1, openId: email, name: config.name ?? email.split("@")[0] ?? "Utilisateur", email, passwordHash: null, loginMethod: "bootstrap_jwt", role: config.role ?? "admin", canManageLogistics: config.role === "super_admin", delegatedSalleSignerUserId: null, privacyConsentVersion: null, privacyConsentAcceptedAt: null, privacyConsentContext: null, failedLoginAttempts: 0, lockedUntil: null, mfaEnabled: false, mfaMethod: null, mfaChallengeToken: null, mfaCodeHash: null, mfaCodeExpiresAt: null, mfaCodeAttempts: 0, mfaTotpSecretEncrypted: null, mfaTotpPendingSecretEncrypted: null, deletionRequestedAt: null, purgeScheduledAt: null, purgedAt: null, legalHold: false, legalHoldReason: null, isActive: true, createdAt: now, updatedAt: now, lastSignedIn: now, }; } function getBootstrapUserConfigs(): BootstrapUserConfig[] { const users: BootstrapUserConfig[] = []; if (ENV.bootstrapUsers) { try { const parsed = JSON.parse(ENV.bootstrapUsers) as BootstrapUserConfig[]; if (Array.isArray(parsed)) { users.push(...parsed); } } catch (error) { console.error("[Auth] BOOTSTRAP_USERS is not valid JSON", error); } } if (ENV.bootstrapAdminEmail && ENV.bootstrapAdminPassword) { users.push({ email: ENV.bootstrapAdminEmail, password: ENV.bootstrapAdminPassword, name: ENV.bootstrapAdminName, role: "super_admin", }); } return users; } function getBootstrapUsers(): User[] { return getBootstrapUserConfigs().map((config, index) => createBootstrapUser(config, index) ); } function findBootstrapUserByCredentials(input: { email: string; password: string }) { const email = normalizeEmail(input.email); const configs = getBootstrapUserConfigs(); const index = configs.findIndex( config => normalizeEmail(config.email) === email && config.password === input.password ); if (index === -1) return null; return createBootstrapUser(configs[index], index); } function findBootstrapUserByOpenId(openId: string) { return getBootstrapUsers().find(user => user.openId === openId) ?? null; } function getJwtSecret() { const secret = ENV.cookieSecret; if (!secret && ENV.isProduction) { throw new Error("JWT_SECRET is required in production"); } return new TextEncoder().encode(secret || "local-dev-secret-change-me"); } function normalizeEmail(email: string) { return email.trim().toLowerCase(); } async function ensureConfiguredOwnerPrivileges(user: User): Promise { if (!user.email || !ENV.adminEmail) return user; const isConfiguredOwner = normalizeEmail(user.email) === normalizeEmail(ENV.adminEmail); if (!isConfiguredOwner) return user; if (user.role === "super_admin" && user.canManageLogistics) { return user; } await db.updateUser(user.id, { role: "super_admin", canManageLogistics: true, }); return { ...user, role: "super_admin", canManageLogistics: true, }; } function parseCookies(cookieHeader: string | undefined) { if (!cookieHeader) return new Map(); return new Map(Object.entries(parseCookieHeader(cookieHeader))); } function hashOtpCode(code: string) { return crypto.createHash("sha256").update(code).digest("hex"); } function buildSecurityCipherKey() { return crypto.createHash("sha256") .update(ENV.cookieSecret || "local-dev-auth-security") .digest(); } function encryptSecuritySecret(value: string) { const iv = crypto.randomBytes(12); const key = buildSecurityCipherKey(); const cipher = crypto.createCipheriv("aes-256-gcm", key, iv); const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); const tag = cipher.getAuthTag(); return `${iv.toString("base64")}.${tag.toString("base64")}.${encrypted.toString("base64")}`; } function decryptSecuritySecret(value?: string | null) { if (!value) return ""; const [ivBase64, tagBase64, encryptedBase64] = value.split("."); if (!ivBase64 || !tagBase64 || !encryptedBase64) return ""; try { const key = buildSecurityCipherKey(); const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(ivBase64, "base64")); decipher.setAuthTag(Buffer.from(tagBase64, "base64")); const decrypted = Buffer.concat([ decipher.update(Buffer.from(encryptedBase64, "base64")), decipher.final(), ]); return decrypted.toString("utf8"); } catch { return ""; } } const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; function encodeBase32(buffer: Buffer) { let bits = 0; let value = 0; let output = ""; for (let index = 0; index < buffer.length; index += 1) { const byte = buffer[index]!; value = (value << 8) | byte; bits += 8; while (bits >= 5) { output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31]; bits -= 5; } } if (bits > 0) { output += BASE32_ALPHABET[(value << (5 - bits)) & 31]; } return output; } function decodeBase32(input: string) { const normalized = input.toUpperCase().replace(/=+$/g, "").replace(/[^A-Z2-7]/g, ""); let bits = 0; let value = 0; const output: number[] = []; for (const char of normalized) { const index = BASE32_ALPHABET.indexOf(char); if (index === -1) continue; value = (value << 5) | index; bits += 5; if (bits >= 8) { output.push((value >>> (bits - 8)) & 255); bits -= 8; } } return Buffer.from(output); } function generateTotpSecret() { return encodeBase32(crypto.randomBytes(20)); } function buildOtpAuthUrl(email: string, secret: string) { const label = encodeURIComponent(`Portail Associations:${email}`); const issuer = encodeURIComponent("Portail Associations"); return `otpauth://totp/${label}?secret=${secret}&issuer=${issuer}&algorithm=SHA1&digits=6&period=30`; } function generateTotpCode(secret: string, timestamp: number) { const key = decodeBase32(secret); const counter = Math.floor(timestamp / 30_000); const counterBuffer = Buffer.alloc(8); counterBuffer.writeBigUInt64BE(BigInt(counter)); const hmac = crypto.createHmac("sha1", key).update(counterBuffer).digest(); const offset = hmac[hmac.length - 1] & 0x0f; const binary = ((hmac[offset] & 0x7f) << 24) | ((hmac[offset + 1] & 0xff) << 16) | ((hmac[offset + 2] & 0xff) << 8) | (hmac[offset + 3] & 0xff); return String(binary % 1_000_000).padStart(6, "0"); } function verifyTotpCode(secret: string, code: string) { const trimmedCode = code.trim(); const now = Date.now(); for (const drift of [-30_000, 0, 30_000]) { if (generateTotpCode(secret, now + drift) === trimmedCode) { return true; } } return false; } function maskEmail(email: string) { const [localPart, domain = ""] = email.split("@"); if (!localPart) return email; const visibleStart = localPart.slice(0, 2); const visibleEnd = localPart.length > 4 ? localPart.slice(-1) : ""; const maskedCore = "*".repeat(Math.max(localPart.length - visibleStart.length - visibleEnd.length, 1)); return `${visibleStart}${maskedCore}${visibleEnd}@${domain}`; } function generateOtpCode() { return String(Math.floor(100000 + Math.random() * 900000)); } function getPasswordHashAlgorithm(passwordHash: string | null | undefined) { if (!passwordHash) return null; const [algorithm] = passwordHash.split(":"); return algorithm || null; } export function getInternalMfaPolicy(role: User["role"]) { return INTERNAL_MFA_POLICY[role as keyof typeof INTERNAL_MFA_POLICY] ?? null; } export function isInternalRole(role: User["role"]) { return Boolean(getInternalMfaPolicy(role)); } function requiresMfaByRole(user: User) { return Boolean(getInternalMfaPolicy(user.role)?.required); } function requiresTotpByRole(user: User) { return getInternalMfaPolicy(user.role)?.enforcedMethod === "authenticator_app"; } function allowsEmailMfaByRole(user: User) { return getInternalMfaPolicy(user.role)?.emailFallbackAllowed !== false; } function getEffectiveMfaMethod(user: User): "email" | "totp" | null { const hasTotp = Boolean(user.mfaMethod === "authenticator_app" && user.mfaTotpSecretEncrypted); if (hasTotp) return "totp"; if ((user.mfaEnabled || requiresMfaByRole(user)) && user.email && allowsEmailMfaByRole(user)) return "email"; return null; } async function hashPassword(password: string) { if (!argon2Async) { const salt = crypto.randomBytes(16).toString("hex"); const derivedKey = (await scryptAsync(password, salt, KEY_LENGTH)) as Buffer; return `scrypt:${salt}:${derivedKey.toString("hex")}`; } const nonce = crypto.randomBytes(16); const derivedKey = (await argon2Async("argon2id", { message: Buffer.from(password), nonce, parallelism: 1, tagLength: 32, memory: 65536, passes: 3, })) as Buffer; return `argon2id:${nonce.toString("base64url")}:${derivedKey.toString("base64url")}`; } export async function hashLocalPassword(password: string) { return hashPassword(password); } async function verifyPassword(password: string, passwordHash: string | null | undefined) { if (!passwordHash) return false; const [algorithm, salt, key] = passwordHash.split(":"); if (algorithm === "argon2id" && salt && key && argon2Async) { const nonce = Buffer.from(salt, "base64url"); const expected = Buffer.from(key, "base64url"); const derivedKey = (await argon2Async("argon2id", { message: Buffer.from(password), nonce, parallelism: 1, tagLength: expected.length, memory: 65536, passes: 3, })) as Buffer; return derivedKey.length === expected.length && crypto.timingSafeEqual(derivedKey, expected); } if (algorithm !== "scrypt" || !salt || !key) return false; const storedKey = Buffer.from(key, "hex"); const derivedKey = (await scryptAsync(password, salt, storedKey.length)) as Buffer; if (storedKey.length !== derivedKey.length) return false; return crypto.timingSafeEqual(storedKey, derivedKey); } async function resetLoginSecurityState(userId: number) { await db.updateUser(userId, { failedLoginAttempts: 0, lockedUntil: null, }); } async function registerLoginFailure(user: User) { const nextAttempts = (user.failedLoginAttempts || 0) + 1; const lockNow = nextAttempts >= LOGIN_FAILURE_LIMIT; await db.updateUser(user.id, { failedLoginAttempts: lockNow ? 0 : nextAttempts, lockedUntil: lockNow ? new Date(Date.now() + LOGIN_LOCK_DURATION_MS) : null, }); } function assertUserNotLocked(user: User) { if (user.lockedUntil && new Date(user.lockedUntil) > new Date()) { throw new AccountLockedError(new Date(user.lockedUntil)); } } async function sendLoginMfaChallenge(user: User) { if (requiresTotpByRole(user) && !user.mfaTotpSecretEncrypted) { throw new Error("Ce rôle interne doit utiliser l'application Authenticator. Faites initialiser le compte avant la prochaine connexion."); } const effectiveMethod = getEffectiveMfaMethod(user); if (effectiveMethod === "totp") { const challengeToken = crypto.randomBytes(24).toString("base64url"); const expiresAt = new Date(Date.now() + MFA_CODE_TTL_MS); await db.updateUser(user.id, { mfaChallengeToken: challengeToken, mfaCodeHash: null, mfaCodeExpiresAt: expiresAt, mfaCodeAttempts: 0, }); return new MfaChallengeRequiredError("totp", challengeToken, expiresAt); } if (!user.email) { throw new Error("Ce compte ne dispose pas d'une adresse email exploitable pour le MFA."); } const mailReady = await canSendOperationalEmails(); if (!mailReady) { throw new Error("Le MFA par email ne peut pas etre active tant que le SMTP n'est pas configure."); } const code = generateOtpCode(); const expiresAt = new Date(Date.now() + MFA_CODE_TTL_MS); const challengeToken = crypto.randomBytes(24).toString("base64url"); await db.updateUser(user.id, { mfaChallengeToken: challengeToken, mfaCodeHash: hashOtpCode(code), mfaCodeExpiresAt: expiresAt, mfaCodeAttempts: 0, }); await sendOperationalEmail({ to: [user.email], subject: "Code de verification - Portail Associations", text: `Votre code de verification est ${code}. Il expire dans 10 minutes.`, html: `

Code de verification

Utilisez ce code pour finaliser votre connexion au Portail Associations :

${code}

Ce code expire dans 10 minutes.

`, }); return new MfaChallengeRequiredError("email", challengeToken, expiresAt, maskEmail(user.email)); } async function signSession(user: User) { const expiresAt = Math.floor((Date.now() + ONE_YEAR_MS) / 1000); return new SignJWT({ userId: user.id, openId: user.openId, } satisfies JwtPayload) .setProtectedHeader({ alg: "HS256", typ: "JWT" }) .setSubject(String(user.id)) .setExpirationTime(expiresAt) .sign(getJwtSecret()); } async function verifySession(token: string | undefined | null) { if (!token) return null; try { const { payload } = await jwtVerify(token, getJwtSecret(), { algorithms: ["HS256"], }); const userId = Number(payload.userId ?? payload.sub); const openId = typeof payload.openId === "string" ? payload.openId : ""; if (!Number.isInteger(userId) || userId <= 0 || !openId) return null; return { userId, openId }; } catch { return null; } } export function setSessionCookie(req: Request, res: Response, token: string) { res.cookie(COOKIE_NAME, token, { ...getSessionCookieOptions(req), maxAge: ONE_YEAR_MS, }); } export function clearSessionCookie(req: Request, res: Response) { res.clearCookie(COOKIE_NAME, { ...getSessionCookieOptions(req), maxAge: -1, }); } export async function registerLocalUser(input: { name: string; email: string; password: string; }) { const email = normalizeEmail(input.email); const existing = await db.getUserByEmail(email); if (existing) { throw new Error("Un compte existe deja avec cette adresse email"); } const bootstrapUser = findBootstrapUserByCredentials(input); if (bootstrapUser) { return bootstrapUser; } const role = ENV.adminEmail && normalizeEmail(ENV.adminEmail) === email ? "super_admin" : "user"; await db.upsertUser({ openId: email, name: input.name.trim(), email, passwordHash: await hashPassword(input.password), loginMethod: "local_jwt", role, lastSignedIn: new Date(), }); const user = await db.getUserByEmail(email); if (!user) throw new Error("Impossible de creer le compte utilisateur"); return user; } export async function loginLocalUser(input: { email: string; password: string }) { const email = normalizeEmail(input.email); const user = await db.getUserByEmail(email); if (user) { assertUserNotLocked(user); if (!(await verifyPassword(input.password, user.passwordHash))) { await registerLoginFailure(user); throw ForbiddenError("Identifiants invalides"); } await resetLoginSecurityState(user.id); if (argon2Async && getPasswordHashAlgorithm(user.passwordHash) === "scrypt") { await db.updateUser(user.id, { passwordHash: await hashPassword(input.password), }); } const refreshedUser = (await db.getUserById(user.id)) ?? user; if (getEffectiveMfaMethod(refreshedUser)) { throw await sendLoginMfaChallenge(refreshedUser); } await db.upsertUser({ openId: user.openId, lastSignedIn: new Date(), }); return ensureConfiguredOwnerPrivileges((await db.getUserById(user.id)) ?? refreshedUser); } const bootstrapUser = findBootstrapUserByCredentials(input); if (bootstrapUser) { return bootstrapUser; } throw ForbiddenError("Identifiants invalides"); } export async function verifyLocalUserMfa(input: { challengeToken: string; code: string }) { const user = await db.getUserByMfaChallengeToken(input.challengeToken); if (!user) { throw ForbiddenError("Verification invalide ou expirée"); } const effectiveMethod = getEffectiveMfaMethod(user); if (!effectiveMethod) { throw ForbiddenError("Verification invalide ou expirée"); } if (user.mfaCodeExpiresAt && new Date(user.mfaCodeExpiresAt) < new Date()) { await db.updateUser(user.id, { mfaChallengeToken: null, mfaCodeHash: null, mfaCodeExpiresAt: null, mfaCodeAttempts: 0, }); throw ForbiddenError("Le code de verification a expiré"); } if ((user.mfaCodeAttempts || 0) >= MFA_CODE_ATTEMPT_LIMIT) { await db.updateUser(user.id, { mfaChallengeToken: null, mfaCodeHash: null, mfaCodeExpiresAt: null, mfaCodeAttempts: 0, lockedUntil: new Date(Date.now() + LOGIN_LOCK_DURATION_MS), }); throw new AccountLockedError(new Date(Date.now() + LOGIN_LOCK_DURATION_MS)); } const isValidCode = effectiveMethod === "totp" ? verifyTotpCode(decryptSecuritySecret(user.mfaTotpSecretEncrypted), input.code) : Boolean(user.mfaCodeHash) && hashOtpCode(input.code.trim()) === user.mfaCodeHash; if (!isValidCode) { await db.updateUser(user.id, { mfaCodeAttempts: (user.mfaCodeAttempts || 0) + 1, }); throw ForbiddenError("Code de verification incorrect"); } await db.updateUser(user.id, { mfaChallengeToken: null, mfaCodeHash: null, mfaCodeExpiresAt: null, mfaCodeAttempts: 0, lastSignedIn: new Date(), }); return ensureConfiguredOwnerPrivileges((await db.getUserById(user.id)) ?? user); } export async function resendLocalUserMfaChallenge(challengeToken: string) { const user = await db.getUserByMfaChallengeToken(challengeToken); if (!user || getEffectiveMfaMethod(user) !== "email") { throw ForbiddenError("Verification invalide ou expirée"); } if (!user.email) { throw ForbiddenError("Adresse email indisponible pour ce compte"); } const code = generateOtpCode(); const expiresAt = new Date(Date.now() + MFA_CODE_TTL_MS); await db.updateUser(user.id, { mfaCodeHash: hashOtpCode(code), mfaCodeExpiresAt: expiresAt, mfaCodeAttempts: 0, }); await sendOperationalEmail({ to: [user.email], subject: "Nouveau code de verification - Portail Associations", text: `Votre nouveau code de verification est ${code}. Il expire dans 10 minutes.`, html: `

Nouveau code de verification

Utilisez ce code pour finaliser votre connexion au Portail Associations :

${code}

Ce code expire dans 10 minutes.

`, }); return { challengeToken, expiresAt, maskedEmail: maskEmail(user.email), }; } export async function startAuthenticatorSetup(user: User) { if (user.loginMethod !== "local_jwt" || !user.email) { throw new Error("Authenticator est disponible uniquement pour les comptes locaux avec email."); } const secret = generateTotpSecret(); await db.updateUser(user.id, { mfaTotpPendingSecretEncrypted: encryptSecuritySecret(secret), }); return { manualEntryKey: secret, otpauthUrl: buildOtpAuthUrl(user.email, secret), }; } export async function confirmAuthenticatorSetup(user: User, code: string) { const freshUser = await db.getUserById(user.id); if (!freshUser) { throw new Error("Compte introuvable"); } const pendingSecret = decryptSecuritySecret(freshUser.mfaTotpPendingSecretEncrypted); if (!pendingSecret) { throw new Error("Aucune configuration Authenticator en attente."); } if (!verifyTotpCode(pendingSecret, code)) { throw new Error("Code Authenticator invalide."); } await db.updateUser(user.id, { mfaEnabled: true, mfaMethod: "authenticator_app", mfaTotpSecretEncrypted: encryptSecuritySecret(pendingSecret), mfaTotpPendingSecretEncrypted: null, }); } export async function cancelAuthenticatorSetup(user: User) { await db.updateUser(user.id, { mfaTotpPendingSecretEncrypted: null, }); } export async function scheduleAccountDeletion(user: User) { if (user.legalHold) { throw new Error("Ce compte est actuellement place en conservation legale et ne peut pas etre supprime."); } const now = new Date(); const purgeAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); await db.updateUser(user.id, { deletionRequestedAt: now, purgeScheduledAt: purgeAt, isActive: false, }); return { deletionRequestedAt: now, purgeScheduledAt: purgeAt }; } export async function cancelScheduledAccountDeletion(user: User) { await db.updateUser(user.id, { deletionRequestedAt: null, purgeScheduledAt: null, isActive: true, }); } export async function runUserPurgeScheduler() { const startedAt = new Date(); const dueUsers = await db.getUsersScheduledForPurge(); let purgedUsers = 0; for (const user of dueUsers) { const association = await db.getAssociationByUserId(user.id); if (association) { await db.updateAssociation(association.id, { sourceDirectoryEntryId: null, nomAssociation: `Association supprimée #${association.id}`, siret: null, rna: null, thematique: null, adresse: null, codePostal: null, ville: null, telephone: null, emailContact: null, siteWeb: null, facebookUrl: null, instagramUrl: null, objetAssociation: null, nomRepresentant: null, fonctionRepresentant: null, gouvernance: null, profileComplete: false, isActive: false, }); } await db.updateUser(user.id, { openId: `purged:${user.id}:${Date.now()}`, name: `Compte supprimé #${user.id}`, email: null, passwordHash: null, isActive: false, mfaEnabled: false, mfaMethod: null, mfaChallengeToken: null, mfaCodeHash: null, mfaCodeExpiresAt: null, mfaCodeAttempts: 0, mfaTotpSecretEncrypted: null, mfaTotpPendingSecretEncrypted: null, purgedAt: new Date(), legalHold: false, legalHoldReason: null, }); await db.createAuditLog({ userId: SYSTEM_ACTOR_USER_ID, action: "data_purge", entityType: "user", entityId: user.id, details: JSON.stringify({ event: "DATA_PURGE", executedAt: new Date().toISOString(), status: "SUCCESS", purgeScheduledAt: user.purgeScheduledAt, }), ipAddress: "system", }); purgedUsers += 1; } const allUsers = await db.getAllUsers(); const pendingDeletionCount = allUsers.filter((item) => item.deletionRequestedAt && !item.purgedAt).length; const purgedTotalCount = allUsers.filter((item) => item.purgedAt).length; await db.setPortalSetting( RETENTION_REPORT_SETTING_KEY, JSON.stringify({ generatedAt: new Date().toISOString(), startedAt: startedAt.toISOString(), reportType: "daily-retention-job", purgedUsers, pendingDeletionCount, purgedTotalCount, }), "Dernier rapport automatique de retention et purge" ); return { generatedAt: new Date().toISOString(), purgedUsers, pendingDeletionCount, purgedTotalCount, }; } export function isAccountLockedError(error: unknown): error is AccountLockedError { return error instanceof AccountLockedError; } export function isMfaCodeRequiredError(error: unknown): error is MfaChallengeRequiredError { return error instanceof MfaChallengeRequiredError; } export async function loginOAuthUser(input: { provider: "google" | "facebook"; providerUserId: string; email: string; name?: string | null; }) { const email = normalizeEmail(input.email); const existingByEmail = await db.getUserByEmail(email); const openId = existingByEmail?.openId || email || `${input.provider}:${input.providerUserId}`; const role = existingByEmail?.role ?? (ENV.adminEmail && normalizeEmail(ENV.adminEmail) === email ? "super_admin" : "user"); await db.upsertUser({ openId, name: input.name?.trim() || existingByEmail?.name || email.split("@")[0] || "Utilisateur", email, loginMethod: `${input.provider}_oauth`, role, lastSignedIn: new Date(), }); if (existingByEmail) { return ensureConfiguredOwnerPrivileges((await db.getUserById(existingByEmail.id)) ?? existingByEmail); } const user = await db.getUserByOpenId(openId); if (!user) { throw new Error("Impossible de finaliser la connexion sociale"); } return ensureConfiguredOwnerPrivileges(user); } export async function createSessionToken(user: User) { return signSession(user); } export async function authenticateRequest(req: Request): Promise { const cookies = parseCookies(req.headers.cookie); const session = await verifySession(cookies.get(COOKIE_NAME)); if (!session) { throw ForbiddenError("Invalid session cookie"); } const user = await db.getUserById(session.userId); const userByOpenId = user ? null : await db.getUserByOpenId(session.openId); const bootstrapUser = findBootstrapUserByOpenId(session.openId); if (!user && userByOpenId && userByOpenId.isActive) { return ensureConfiguredOwnerPrivileges(userByOpenId); } if (!user && bootstrapUser) { return bootstrapUser; } if (!user || user.openId !== session.openId || !user.isActive) { throw ForbiddenError("User not found"); } return ensureConfiguredOwnerPrivileges(user); }