# Analyse complète du code Calendar - Sécurité, Fiabilité, Performance, Qualité ## 📋 Vue d'ensemble **Fichier analysé** : `backend/src/routes/calendar.ts` **Lignes de code** : ~180 **Dépendances** : Express, Zod, Node Crypto **Fonctionnalités** : OAuth Google/Outlook, gestion connexions calendrier --- ## 🔒 Sécurité : 3/10 ⚠️ CRITIQUE ### ❌ Vulnérabilités critiques #### 1. **Stockage des tokens en mémoire (lignes 21-22)** ```typescript const connectionsByProfile = new Map(); const pendingStates = new Map(); ``` **Risques** : - Perte totale des connexions au redémarrage du serveur - Pas de persistance - Pas de chiffrement - Exposition en cas de dump mémoire **Impact** : 🔴 CRITIQUE **Recommandation** : ```typescript // Utiliser une base de données avec chiffrement import { Database } from "./database"; import { TokenEncryption } from "./security/encryption"; class SecureTokenStorage { async saveToken(userId: string, token: string): Promise { const { encrypted, iv, authTag } = TokenEncryption.encrypt(token); await Database.tokens.insert({ userId, encryptedToken: encrypted, iv, authTag, createdAt: new Date() }); } } ``` #### 2. **Client secrets dans le code (lignes 34-48)** ```typescript const OAUTH_CONFIG = { google: { clientId: process.env.GOOGLE_CLIENT_ID || "YOUR_GOOGLE_CLIENT_ID", // ❌ // ... } }; ``` **Risques** : - Valeurs par défaut dangereuses - Pas de validation des variables d'environnement - Potentiel leak si `.env` n'est pas dans `.gitignore` **Impact** : 🔴 CRITIQUE **Recommandation** : ```typescript function getRequiredEnv(key: string): string { const value = process.env[key]; if (!value || value.startsWith("YOUR_") || value.includes("_here")) { throw new Error(`Missing required environment variable: ${key}`); } return value; } const OAUTH_CONFIG = { google: { clientId: getRequiredEnv("GOOGLE_CLIENT_ID"), clientSecret: getRequiredEnv("GOOGLE_CLIENT_SECRET"), redirectUri: getRequiredEnv("GOOGLE_REDIRECT_URI") } }; ``` #### 3. **Pas de validation du state OAuth (ligne 99)** ```typescript const pending = pendingStates.get(state); if (!pending) return res.json({ success: false, error: "Invalid state" }); ``` **Risques** : - Attaque CSRF possible - Pas d'expiration du state - Pas de vérification de l'origine **Impact** : 🟠 MOYEN **Recommandation** : ```typescript type PendingState = { profileId: string; provider: CalendarProvider; connectionId: string; createdAt: number; expiresAt: number; }; // Nettoyer les states expirés setInterval(() => { const now = Date.now(); for (const [state, data] of pendingStates.entries()) { if (data.expiresAt < now) { pendingStates.delete(state); } } }, 60000); // Toutes les minutes // Valider avec expiration const pending = pendingStates.get(state); if (!pending || pending.expiresAt < Date.now()) { return res.status(400).json({ success: false, error: "State expired or invalid" }); } ``` #### 4. **Pas de protection contre les attaques par timing** **Risque** : Les comparaisons de strings peuvent révéler des informations via timing attacks **Recommandation** : ```typescript import crypto from "crypto"; function constantTimeCompare(a: string, b: string): boolean { if (a.length !== b.length) return false; return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); } ``` #### 5. **Endpoints sans authentification** Tous les endpoints sont accessibles sans authentification utilisateur ! **Recommandation** : ```typescript import { authenticate } from "../middleware/auth"; calendarRouter.use(authenticate); // Protéger toutes les routes // Ou individuellement : calendarRouter.get("/:profileId/connections", authenticate, (req, res) => { // Vérifier que req.user.id === req.params.profileId if (req.user.id !== req.params.profileId) { return res.status(403).json({ error: "Forbidden" }); } // ... }); ``` --- ## 🛡️ Fiabilité : 4/10 ⚠️ ### ❌ Points faibles #### 1. **Gestion d'erreurs insuffisante** ```typescript catch (e) { console.error("OAuth start error:", e); res.status(400).json({ message: "Invalid request" }); // Trop générique } ``` **Problème** : - Messages d'erreur génériques - Pas de distinction entre erreurs client/serveur - Logs insuffisants - Pas de monitoring **Recommandation** : ```typescript import { ZodError } from "zod"; import { logger } from "../utils/logger"; try { // ... } catch (e) { if (e instanceof ZodError) { logger.warn("Validation error", { errors: e.errors, endpoint: req.path }); return res.status(400).json({ error: "Validation error", details: e.errors.map(err => ({ field: err.path.join("."), message: err.message })) }); } logger.error("OAuth start failed", { error: e, profileId: req.body.profileId }); return res.status(500).json({ error: "Internal server error" }); } ``` #### 2. **Pas de retry logic** Si l'API Google/Outlook échoue temporairement, pas de retry automatique. **Recommandation** : ```typescript async function fetchWithRetry( url: string, options: RequestInit, maxRetries = 3 ): Promise { for (let i = 0; i < maxRetries; i++) { try { const response = await fetch(url, options); if (response.ok || response.status < 500) return response; } catch (e) { if (i === maxRetries - 1) throw e; await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000)); } } throw new Error("Max retries exceeded"); } ``` #### 3. **Pas de validation des tokens reçus** Après l'échange code → token, pas de validation de la structure du token. **Recommandation** : ```typescript const tokenResponseSchema = z.object({ access_token: z.string().min(1), refresh_token: z.string().optional(), expires_in: z.number().positive(), token_type: z.literal("Bearer"), scope: z.string() }); const tokens = tokenResponseSchema.parse(await response.json()); ``` #### 4. **Race conditions possibles** Si deux requêtes modifient la même connexion simultanément : ```typescript list[idx] = { ...list[idx], status: "connected", lastSyncedAt: now }; connectionsByProfile.set(profileId, list); ``` **Recommandation** : Utiliser une base de données avec transactions ACID --- ## ⚡ Performance : 5/10 ⚠️ ### Points faibles #### 1. **Pas de cache** Chaque requête refait potentiellement le même appel API. **Recommandation** : ```typescript import NodeCache from "node-cache"; const connectionCache = new NodeCache({ stdTTL: 300, // 5 minutes checkperiod: 60 }); calendarRouter.get("/:profileId/connections", (req, res) => { const profileId = req.params.profileId; const cacheKey = `connections:${profileId}`; const cached = connectionCache.get(cacheKey); if (cached) return res.json(cached); const list = connectionsByProfile.get(profileId) ?? []; connectionCache.set(cacheKey, list); res.json(list); }); ``` #### 2. **Pas de pagination** Si un utilisateur a 1000 connexions (peu probable mais possible), tout est retourné d'un coup. **Recommandation** : ```typescript calendarRouter.get("/:profileId/connections", (req, res) => { const page = parseInt(req.query.page as string) || 1; const limit = parseInt(req.query.limit as string) || 20; const skip = (page - 1) * limit; const list = connectionsByProfile.get(profileId) ?? []; const paginated = list.slice(skip, skip + limit); res.json({ data: paginated, pagination: { page, limit, total: list.length, totalPages: Math.ceil(list.length / limit) } }); }); ``` #### 3. **Pas de rate limiting** Un attaquant peut spam les endpoints OAuth. **Recommandation** : ```typescript import rateLimit from "express-rate-limit"; const oauthLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, // 5 tentatives max message: "Trop de tentatives OAuth, réessayez plus tard" }); calendarRouter.post("/:provider/oauth/start", oauthLimiter, (req, res) => { // ... }); ``` #### 4. **Synchronisation bloquante** Le endpoint `/refresh` pourrait bloquer si l'API externe est lente. **Recommandation** : ```typescript // Synchronisation asynchrone avec job queue import Bull from "bull"; const syncQueue = new Bull("calendar-sync"); syncQueue.process(async (job) => { const { profileId, connectionId } = job.data; // Faire la sync ici }); calendarRouter.post("/:profileId/connections/:connectionId/refresh", async (req, res) => { const job = await syncQueue.add({ profileId: req.params.profileId, connectionId: req.params.connectionId }); res.json({ jobId: job.id, status: "queued" }); }); ``` --- ## ✨ Qualité du code : 6/10 ### ✅ Points positifs 1. **Utilisation de Zod pour la validation** (lignes 26-34) 2. **Types TypeScript bien définis** (lignes 6-19) 3. **Séparation des concerns** (Router séparé) 4. **Code lisible** et bien structuré ### ❌ Points à améliorer #### 1. **Pas de tests** **Recommandation** : ```typescript // calendar.test.ts import request from "supertest"; import { app } from "../app"; describe("Calendar OAuth", () => { describe("POST /:provider/oauth/start", () => { it("should return auth URL for Google", async () => { const response = await request(app) .post("/api/calendar/google/oauth/start") .send({ profileId: "test-user", state: "random-state-123" }); expect(response.status).toBe(200); expect(response.body.authUrl).toContain("accounts.google.com"); expect(response.body.authUrl).toContain("response_type=code"); }); it("should reject invalid provider", async () => { const response = await request(app) .post("/api/calendar/invalid/oauth/start") .send({ profileId: "test-user", state: "random-state-123" }); expect(response.status).toBe(400); }); }); }); ``` #### 2. **Manque de documentation** **Recommandation** : ```typescript /** * Démarre le flow OAuth pour un provider de calendrier * * @route POST /api/calendar/:provider/oauth/start * @param provider - "google" ou "outlook" * @body profileId - ID de l'utilisateur * @body state - Token CSRF (min 6 caractères) * @returns authUrl - URL de redirection OAuth * @returns state - Le state pour vérification ultérieure * * @example * POST /api/calendar/google/oauth/start * { * "profileId": "user-123", * "state": "csrf-token-abc123" * } * * Response: * { * "authUrl": "https://accounts.google.com/o/oauth2/v2/auth?...", * "state": "csrf-token-abc123" * } */ calendarRouter.post("/:provider/oauth/start", (req, res) => { // ... }); ``` #### 3. **Constantes magiques** ```typescript const { profileId, state } = oauthStartBody.parse(req.body ?? {}); // Pourquoi {} par défaut ? ``` **Recommandation** : ```typescript const DEFAULT_REQUEST_BODY = {}; const MIN_STATE_LENGTH = 6; const oauthStartBody = z.object({ profileId: z.string().min(1), state: z.string().min(MIN_STATE_LENGTH, "State must be at least 6 characters") }); ``` #### 4. **Duplication de code** La construction de l'URL OAuth pour Google et Outlook est similaire. **Recommandation** : ```typescript function buildOAuthUrl( provider: CalendarProvider, config: typeof OAUTH_CONFIG[CalendarProvider], state: string ): string { const baseUrls = { google: "https://accounts.google.com/o/oauth2/v2/auth", outlook: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" }; const commonParams = { client_id: config.clientId, redirect_uri: config.redirectUri, response_type: "code", scope: config.scope, state }; const providerParams = provider === "google" ? { ...commonParams, access_type: "offline", prompt: "consent" } : { ...commonParams, response_mode: "query" }; return `${baseUrls[provider]}?${new URLSearchParams(providerParams).toString()}`; } ``` --- ## 📊 Import planning (IA, Google Agenda, Outlook) ### Architecture recommandée ```typescript // services/calendar-import.service.ts export class CalendarImportService { /** * Importe les événements depuis un provider */ async importEvents( connectionId: string, options: ImportOptions ): Promise { const connection = await this.getConnection(connectionId); switch (connection.provider) { case "google": return this.importFromGoogle(connection, options); case "outlook": return this.importFromOutlook(connection, options); default: throw new Error(`Unsupported provider: ${connection.provider}`); } } /** * Importe depuis Google Calendar API */ private async importFromGoogle( connection: ConnectedCalendar, options: ImportOptions ): Promise { const accessToken = await this.getAccessToken(connection); const response = await fetch( `https://www.googleapis.com/calendar/v3/calendars/primary/events?${new URLSearchParams({ timeMin: options.startDate.toISOString(), timeMax: options.endDate.toISOString(), maxResults: String(options.maxResults || 250), singleEvents: "true", orderBy: "startTime" })}`, { headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" } } ); if (!response.ok) { if (response.status === 401) { // Token expiré, refresh await this.refreshToken(connection); return this.importFromGoogle(connection, options); // Retry } throw new Error(`Google API error: ${response.status}`); } const data = await response.json(); return this.normalizeGoogleEvents(data.items); } /** * Importe depuis Microsoft Graph API */ private async importFromOutlook( connection: ConnectedCalendar, options: ImportOptions ): Promise { const accessToken = await this.getAccessToken(connection); const response = await fetch( `https://graph.microsoft.com/v1.0/me/calendar/events?${new URLSearchParams({ $filter: `start/dateTime ge '${options.startDate.toISOString()}' and end/dateTime le '${options.endDate.toISOString()}'`, $top: String(options.maxResults || 250), $orderby: "start/dateTime" })}`, { headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" } } ); if (!response.ok) { if (response.status === 401) { await this.refreshToken(connection); return this.importFromOutlook(connection, options); } throw new Error(`Microsoft API error: ${response.status}`); } const data = await response.json(); return this.normalizeOutlookEvents(data.value); } /** * Normalise les événements Google vers format commun */ private normalizeGoogleEvents(events: any[]): ImportResult { const normalized = events.map(event => ({ id: generateUUID(), externalId: event.id, source: "google" as const, title: event.summary || "(Sans titre)", description: event.description, startDate: event.start.dateTime || event.start.date, endDate: event.end.dateTime || event.end.date, isAllDay: !event.start.dateTime, location: event.location, attendees: event.attendees?.map((a: any) => ({ email: a.email, name: a.displayName, status: a.responseStatus })), recurrence: event.recurrence, createdAt: event.created, updatedAt: event.updated })); return { events: normalized, count: normalized.length, source: "google", importedAt: new Date().toISOString() }; } /** * Normalise les événements Outlook vers format commun */ private normalizeOutlookEvents(events: any[]): ImportResult { const normalized = events.map(event => ({ id: generateUUID(), externalId: event.id, source: "outlook" as const, title: event.subject || "(Sans titre)", description: event.bodyPreview, startDate: event.start.dateTime, endDate: event.end.dateTime, isAllDay: event.isAllDay, location: event.location?.displayName, attendees: event.attendees?.map((a: any) => ({ email: a.emailAddress.address, name: a.emailAddress.name, status: a.status.response })), recurrence: event.recurrence, createdAt: event.createdDateTime, updatedAt: event.lastModifiedDateTime })); return { events: normalized, count: normalized.length, source: "outlook", importedAt: new Date().toISOString() }; } /** * Utilise l'IA pour enrichir les événements importés */ async enrichWithAI(events: NormalizedEvent[]): Promise { const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); return Promise.all(events.map(async (event) => { const prompt = ` Analyse cet événement de calendrier et catégorise-le : Titre: ${event.title} Description: ${event.description || "N/A"} Lieu: ${event.location || "N/A"} Retourne un JSON avec: - category: "work" | "personal" | "family" | "school" | "health" | "other" - priority: "low" | "medium" | "high" - tags: string[] (max 5 tags pertinents) - suggestedReminder: number (minutes avant l'événement, 0 si non pertinent) `; const response = await openai.chat.completions.create({ model: "gpt-4o-mini", messages: [{ role: "user", content: prompt }], response_format: { type: "json_object" } }); const aiEnrichment = JSON.parse(response.choices[0].message.content); return { ...event, ...aiEnrichment, confidence: 0.85 // Confiance dans la catégorisation IA }; })); } } // Types type ImportOptions = { startDate: Date; endDate: Date; maxResults?: number; }; type ImportResult = { events: NormalizedEvent[]; count: number; source: "google" | "outlook"; importedAt: string; }; type NormalizedEvent = { id: string; externalId: string; source: "google" | "outlook"; title: string; description?: string; startDate: string; endDate: string; isAllDay: boolean; location?: string; attendees?: Array<{ email: string; name?: string; status?: string; }>; recurrence?: any; createdAt: string; updatedAt: string; }; type EnrichedEvent = NormalizedEvent & { category: "work" | "personal" | "family" | "school" | "health" | "other"; priority: "low" | "medium" | "high"; tags: string[]; suggestedReminder: number; confidence: number; }; ``` ### Endpoint d'import ```typescript // routes/calendar.ts (à ajouter) calendarRouter.post("/:profileId/connections/:connectionId/import", async (req, res) => { try { const { profileId, connectionId } = req.params; const { startDate, endDate, enrichWithAI = false } = req.body; const importService = new CalendarImportService(); let result = await importService.importEvents(connectionId, { startDate: new Date(startDate), endDate: new Date(endDate) }); if (enrichWithAI) { result.events = await importService.enrichWithAI(result.events); } // Sauvegarder dans la base await saveEventsToDatabase(profileId, result.events); res.json({ success: true, imported: result.count, enriched: enrichWithAI, importedAt: result.importedAt }); } catch (error) { logger.error("Import failed", { error, profileId, connectionId }); res.status(500).json({ error: "Import failed" }); } }); ``` --- ## 📈 Score global et recommandations | Critère | Score | Priorité amélioration | |---------|-------|----------------------| | **Sécurité** | 3/10 | 🔴 CRITIQUE | | **Fiabilité** | 4/10 | 🟠 HAUTE | | **Performance** | 5/10 | 🟡 MOYENNE | | **Qualité code** | 6/10 | 🟡 MOYENNE | | **Import/Sync** | N/A | 🔴 À IMPLÉMENTER | ### Roadmap d'amélioration #### Phase 1 - Sécurité CRITIQUE (1 semaine) 1. ✅ Implémenter chiffrement tokens (AES-256-GCM) 2. ✅ Ajouter validation stricte variables d'environnement 3. ✅ Implémenter expiration des states OAuth 4. ✅ Ajouter middleware d'authentification 5. ✅ Révoquer clé OpenAI exposée #### Phase 2 - Fiabilité (1 semaine) 1. ✅ Migrer vers base de données (PostgreSQL + Prisma) 2. ✅ Implémenter gestion d'erreurs robuste 3. ✅ Ajouter retry logic avec backoff exponentiel 4. ✅ Implémenter logging structuré (Winston/Pino) 5. ✅ Ajouter monitoring (Sentry/DataDog) #### Phase 3 - Import/Synchronisation (2 semaines) 1. ✅ Implémenter échange code OAuth → tokens 2. ✅ Implémenter refresh token automatique 3. ✅ Créer CalendarImportService 4. ✅ Normaliser événements Google/Outlook 5. ✅ Intégrer enrichissement IA (optionnel) 6. ✅ Implémenter sync incrémentale (webhook si possible) #### Phase 4 - Performance (1 semaine) 1. ✅ Ajouter cache Redis 2. ✅ Implémenter pagination 3. ✅ Ajouter rate limiting 4. ✅ Créer job queue pour sync asynchrone 5. ✅ Optimiser requêtes base de données #### Phase 5 - Qualité (1 semaine) 1. ✅ Écrire tests unitaires (Jest) 2. ✅ Écrire tests d'intégration (Supertest) 3. ✅ Ajouter documentation OpenAPI/Swagger 4. ✅ Refactorer code dupliqué 5. ✅ Setup CI/CD avec tests --- ## 💡 Conclusion Le code actuel est un **prototype fonctionnel** mais **NON PRODUCTION-READY**. **Forces** : - Structure claire - Utilisation de TypeScript - Validation avec Zod - Correction de l'erreur OAuth "response_type" **Faiblesses critiques** : - Aucune sécurité (tokens en clair, pas d'auth) - Pas de persistance (perte au redémarrage) - Pas de gestion d'erreurs robuste - Pas de tests - Import/sync calendrier non implémenté **Temps estimé pour production** : 6-8 semaines avec 1 développeur full-time **Priorité immédiate** : 1. 🔴 Chiffrement tokens 2. 🔴 Base de données 3. 🟠 Implémentation OAuth complète 4. 🟠 Import Google/Outlook 5. 🟡 Tests et monitoring