Complete family planning application with: - React frontend with TypeScript - Node.js/Express backend with TypeScript - Python ingestion service for document processing - Planning ingestion service with LLM integration - Shared UI components and type definitions - OAuth integration for calendar synchronization - Comprehensive documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
22 KiB
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)
const connectionsByProfile = new Map<string, ConnectedCalendar[]>();
const pendingStates = new Map<string, { profileId: string; provider: CalendarProvider; connectionId: string }>();
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 :
// 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<void> {
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)
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
.envn'est pas dans.gitignore
Impact : 🔴 CRITIQUE Recommandation :
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)
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 :
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 :
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 :
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
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 :
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 :
async function fetchWithRetry(
url: string,
options: RequestInit,
maxRetries = 3
): Promise<Response> {
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 :
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 :
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 :
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 :
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 :
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 :
// 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
- Utilisation de Zod pour la validation (lignes 26-34)
- Types TypeScript bien définis (lignes 6-19)
- Séparation des concerns (Router séparé)
- Code lisible et bien structuré
❌ Points à améliorer
1. Pas de tests
Recommandation :
// 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 :
/**
* 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
const { profileId, state } = oauthStartBody.parse(req.body ?? {}); // Pourquoi {} par défaut ?
Recommandation :
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 :
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
// services/calendar-import.service.ts
export class CalendarImportService {
/**
* Importe les événements depuis un provider
*/
async importEvents(
connectionId: string,
options: ImportOptions
): Promise<ImportResult> {
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<ImportResult> {
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<ImportResult> {
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<EnrichedEvent[]> {
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
// 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)
- ✅ Implémenter chiffrement tokens (AES-256-GCM)
- ✅ Ajouter validation stricte variables d'environnement
- ✅ Implémenter expiration des states OAuth
- ✅ Ajouter middleware d'authentification
- ✅ Révoquer clé OpenAI exposée
Phase 2 - Fiabilité (1 semaine)
- ✅ Migrer vers base de données (PostgreSQL + Prisma)
- ✅ Implémenter gestion d'erreurs robuste
- ✅ Ajouter retry logic avec backoff exponentiel
- ✅ Implémenter logging structuré (Winston/Pino)
- ✅ Ajouter monitoring (Sentry/DataDog)
Phase 3 - Import/Synchronisation (2 semaines)
- ✅ Implémenter échange code OAuth → tokens
- ✅ Implémenter refresh token automatique
- ✅ Créer CalendarImportService
- ✅ Normaliser événements Google/Outlook
- ✅ Intégrer enrichissement IA (optionnel)
- ✅ Implémenter sync incrémentale (webhook si possible)
Phase 4 - Performance (1 semaine)
- ✅ Ajouter cache Redis
- ✅ Implémenter pagination
- ✅ Ajouter rate limiting
- ✅ Créer job queue pour sync asynchrone
- ✅ Optimiser requêtes base de données
Phase 5 - Qualité (1 semaine)
- ✅ Écrire tests unitaires (Jest)
- ✅ Écrire tests d'intégration (Supertest)
- ✅ Ajouter documentation OpenAPI/Swagger
- ✅ Refactorer code dupliqué
- ✅ 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 :
- 🔴 Chiffrement tokens
- 🔴 Base de données
- 🟠 Implémentation OAuth complète
- 🟠 Import Google/Outlook
- 🟡 Tests et monitoring