import { Router, Request, Response } from "express"; import { AppDataSource } from "../config/data-source"; import logger from "../utils/logger"; import { User } from "../entities/User"; import { UserSubscription } from "../entities/UserSubscription"; import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware"; const router = Router(); const COOKIE_NAME = "od_token"; const REFRESH_COOKIE_NAME = "od_refresh"; const COOKIE_OPTIONS = { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "strict" as const, maxAge: 7 * 24 * 60 * 60 * 1000, // 7 jours }; const REFRESH_COOKIE_OPTIONS = { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "strict" as const, maxAge: 30 * 24 * 60 * 60 * 1000, // 30 jours }; /** Upsert user en DB depuis un profil SuperOAuth */ async function upsertUser(oauthUser: { id: string; email: string | null; nickname: string }): Promise { const userRepo = AppDataSource.getRepository(User); let dbUser = await userRepo.findOne({ where: { superOAuthId: oauthUser.id } }); if (!dbUser) { dbUser = userRepo.create({ superOAuthId: oauthUser.id, email: oauthUser.email, nickname: oauthUser.nickname }); } else { dbUser.email = oauthUser.email; dbUser.nickname = oauthUser.nickname; } await userRepo.save(dbUser); } /** * POST /api/auth/login * Proxy email/password vers SuperOAuth → pose le cookie httpOnly. */ router.post("/login", async (req: Request, res: Response): Promise => { const { email, password } = req.body as { email?: string; password?: string }; if (!email || !password) { res.status(400).json({ success: false, error: "MISSING_CREDENTIALS" }); return; } const superOAuthUrl = process.env.SUPER_OAUTH_URL; if (!superOAuthUrl) { res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); return; } try { const response = await fetch(`${superOAuthUrl}/api/v1/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), }); const data = await response.json() as { success: boolean; data?: { user: { id: string; email: string | null; nickname: string }; tokens: { accessToken: string; refreshToken?: string } }; message?: string; }; if (!response.ok || !data.data?.tokens?.accessToken) { res.status(401).json({ success: false, error: "INVALID_CREDENTIALS" }); return; } await upsertUser(data.data.user); res.cookie(COOKIE_NAME, data.data.tokens.accessToken, COOKIE_OPTIONS); if (data.data.tokens.refreshToken) { res.cookie(REFRESH_COOKIE_NAME, data.data.tokens.refreshToken, REFRESH_COOKIE_OPTIONS); } res.json({ success: true, data: { user: data.data.user } }); } catch (err) { logger.error("POST /auth/login — auth service unavailable", { err }); res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE" }); } }); /** * POST /api/auth/session * Reçoit le token depuis le callback SuperOAuth, * le valide, puis le pose en httpOnly cookie. */ router.post("/session", async (req: Request, res: Response): Promise => { const { token } = req.body as { token?: string }; if (!token) { res.status(400).json({ success: false, error: "MISSING_TOKEN" }); return; } const superOAuthUrl = process.env.SUPER_OAUTH_URL; if (!superOAuthUrl) { res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); return; } try { const response = await fetch(`${superOAuthUrl}/api/v1/auth/token/validate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }), }); const data = await response.json() as { success: boolean; data?: { valid: boolean; user?: object }; error?: string; }; if (!response.ok || !data.data?.valid || !data.data.user) { res.status(401).json({ success: false, error: "INVALID_TOKEN" }); return; } await upsertUser(data.data.user as { id: string; email: string | null; nickname: string }); res.cookie(COOKIE_NAME, token, COOKIE_OPTIONS); res.json({ success: true, data: { user: data.data.user } }); } catch (err) { logger.error("POST /auth/session — auth service unavailable", { err }); res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE" }); } }); /** * POST /api/auth/refresh * Échange le refresh token contre un nouvel access token via SuperOAuth. */ router.post("/refresh", async (req: Request, res: Response): Promise => { const refreshToken = (req.cookies as Record)?.[REFRESH_COOKIE_NAME]; if (!refreshToken) { res.status(401).json({ success: false, error: "NO_REFRESH_TOKEN" }); return; } const superOAuthUrl = process.env.SUPER_OAUTH_URL; if (!superOAuthUrl) { res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); return; } try { const response = await fetch(`${superOAuthUrl}/api/v1/auth/refresh`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken }), }); const data = await response.json() as { success: boolean; data?: { accessToken: string; refreshToken?: string }; error?: string; }; if (!response.ok || !data.data?.accessToken) { res.clearCookie(COOKIE_NAME); res.clearCookie(REFRESH_COOKIE_NAME); res.status(401).json({ success: false, error: "REFRESH_FAILED" }); return; } res.cookie(COOKIE_NAME, data.data.accessToken, COOKIE_OPTIONS); if (data.data.refreshToken) { res.cookie(REFRESH_COOKIE_NAME, data.data.refreshToken, REFRESH_COOKIE_OPTIONS); } res.json({ success: true }); } catch (err) { logger.error("POST /auth/refresh — auth service unavailable", { err }); res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE" }); } }); /** * POST /api/auth/logout * Supprime le cookie de session. */ router.post("/logout", (_req: Request, res: Response): void => { res.clearCookie(COOKIE_NAME); res.clearCookie(REFRESH_COOKIE_NAME); res.json({ success: true }); }); /** * GET /api/auth/me * Retourne l'utilisateur courant + rôles locaux + plan actif + avatar. */ router.get("/me", requireAuth, async (req: Request, res: Response): Promise => { const { user } = req as AuthenticatedRequest; const localUser = await AppDataSource.getRepository(User).findOne({ where: { superOAuthId: user.id }, relations: ["userRoles", "userRoles.role"], }); const roles = localUser?.userRoles.map((ur) => ur.role.slug) ?? []; let plan: { slug: string; name: string; level: number } | null = null; let subscriptionDate: string | null = null; if (localUser) { const now = new Date(); const activeSub = await AppDataSource.getRepository(UserSubscription) .createQueryBuilder("sub") .leftJoinAndSelect("sub.plan", "plan") .where("sub.userId = :userId", { userId: localUser.id }) .andWhere("sub.status IN (:...statuses)", { statuses: ["active", "trial"] }) .andWhere("(sub.endsAt IS NULL OR sub.endsAt > :now)", { now }) .orderBy("plan.level", "DESC") .addOrderBy("sub.startsAt", "DESC") .getOne(); if (activeSub) { plan = { slug: activeSub.plan.slug, name: activeSub.plan.name, level: activeSub.plan.level }; subscriptionDate = activeSub.startsAt.toISOString(); } } res.json({ success: true, data: { user: { ...user, avatar: localUser?.avatar ?? null, roles, plan, subscriptionDate, }, }, }); }); /** * GET /api/auth/me/optional * Retourne l'utilisateur courant ou null si non authentifié (pas de 401). */ router.get("/me/optional", async (req: Request, res: Response): Promise => { const token = req.headers.authorization?.split(" ")[1] ?? (req.cookies as Record)?.od_token; if (!token) { res.json({ success: true, data: { user: null } }); return; } const superOAuthUrl = process.env.SUPER_OAUTH_URL; if (!superOAuthUrl) { res.json({ success: true, data: { user: null } }); return; } try { const response = await fetch(`${superOAuthUrl}/api/v1/auth/token/validate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }), }); const data = await response.json() as { success: boolean; data?: { valid: boolean; user?: object }; }; if (!response.ok || !data.data?.valid || !data.data.user) { res.json({ success: true, data: { user: null } }); return; } res.json({ success: true, data: { user: data.data.user } }); } catch (err) { logger.error("GET /auth/me/optional — auth service unavailable", { err }); res.json({ success: true, data: { user: null } }); } }); export default router;