import { randomBytes } from "node:crypto"; import type { Express, Request, Response } from "express"; import { parse as parseCookieHeader } from "cookie"; import { createSessionToken, loginOAuthUser, setSessionCookie } from "./_core/auth"; import { getSessionCookieOptions } from "./_core/cookies"; import { ENV } from "./_core/env"; const GOOGLE_STATE_COOKIE = "oauth_state_google"; const FACEBOOK_STATE_COOKIE = "oauth_state_facebook"; const OAUTH_STATE_MAX_AGE_MS = 10 * 60 * 1000; function parseCookies(cookieHeader: string | undefined) { if (!cookieHeader) return new Map(); return new Map(Object.entries(parseCookieHeader(cookieHeader))); } function buildBaseUrl(req: Request) { if (ENV.appBaseUrl) { return ENV.appBaseUrl.replace(/\/$/, ""); } const forwardedProto = req.headers["x-forwarded-proto"]; const forwardedHost = req.headers["x-forwarded-host"]; const proto = typeof forwardedProto === "string" ? forwardedProto.split(",")[0]?.trim() : req.protocol; const host = typeof forwardedHost === "string" ? forwardedHost.split(",")[0]?.trim() : req.get("host"); return `${proto || "http"}://${host}`; } function getCallbackUrl(req: Request, provider: "google" | "facebook") { return `${buildBaseUrl(req)}/auth/${provider}/callback`; } function redirectToLogin(res: Response, message: string) { res.redirect(`/login?authError=${encodeURIComponent(message)}`); } function setOAuthStateCookie(req: Request, res: Response, name: string, value: string) { res.cookie(name, value, { ...getSessionCookieOptions(req), maxAge: OAUTH_STATE_MAX_AGE_MS, }); } function clearOAuthStateCookie(req: Request, res: Response, name: string) { res.clearCookie(name, { ...getSessionCookieOptions(req), maxAge: -1, }); } function readAndValidateState(req: Request, res: Response, cookieName: string, receivedState: string | null) { const cookies = parseCookies(req.headers.cookie); const expectedState = cookies.get(cookieName) ?? ""; clearOAuthStateCookie(req, res, cookieName); return Boolean(receivedState && expectedState && receivedState === expectedState); } function createOAuthState() { return randomBytes(24).toString("hex"); } function isGoogleConfigured() { return Boolean(ENV.googleClientId && ENV.googleClientSecret); } function isFacebookConfigured() { return Boolean(ENV.facebookAppId && ENV.facebookAppSecret); } async function finishLogin(req: Request, res: Response, userData: { provider: "google" | "facebook"; providerUserId: string; email: string; name?: string | null; }) { const user = await loginOAuthUser(userData); const token = await createSessionToken(user); setSessionCookie(req, res, token); res.redirect(user.role === "service_terrain" ? "/terrain" : "/dashboard"); } export function getOAuthProviderStatus() { return { google: isGoogleConfigured(), facebook: isFacebookConfigured(), }; } export function registerSocialAuthRoutes(app: Express) { app.get("/auth/google/start", (req, res) => { if (!isGoogleConfigured()) { redirectToLogin(res, "Connexion Google non configurée"); return; } const state = createOAuthState(); setOAuthStateCookie(req, res, GOOGLE_STATE_COOKIE, state); const params = new URLSearchParams({ client_id: ENV.googleClientId, redirect_uri: getCallbackUrl(req, "google"), response_type: "code", scope: "openid email profile", state, access_type: "online", include_granted_scopes: "true", prompt: "select_account", }); res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`); }); app.get("/auth/google/callback", async (req, res) => { try { if (!isGoogleConfigured()) { redirectToLogin(res, "Connexion Google non configurée"); return; } const code = typeof req.query.code === "string" ? req.query.code : ""; const state = typeof req.query.state === "string" ? req.query.state : null; if (!code || !readAndValidateState(req, res, GOOGLE_STATE_COOKIE, state)) { redirectToLogin(res, "Connexion Google invalide ou expirée"); return; } const tokenResponse = await fetch("https://oauth2.googleapis.com/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ code, client_id: ENV.googleClientId, client_secret: ENV.googleClientSecret, redirect_uri: getCallbackUrl(req, "google"), grant_type: "authorization_code", }), }); if (!tokenResponse.ok) { redirectToLogin(res, "Google a refusé la connexion"); return; } const tokenData = await tokenResponse.json() as { access_token?: string }; if (!tokenData.access_token) { redirectToLogin(res, "Jeton Google manquant"); return; } const userInfoResponse = await fetch("https://openidconnect.googleapis.com/v1/userinfo", { headers: { authorization: `Bearer ${tokenData.access_token}`, }, }); if (!userInfoResponse.ok) { redirectToLogin(res, "Impossible de récupérer le profil Google"); return; } const userInfo = await userInfoResponse.json() as { sub?: string; email?: string; email_verified?: boolean; name?: string; }; if (!userInfo.sub || !userInfo.email || userInfo.email_verified === false) { redirectToLogin(res, "Le compte Google doit fournir une adresse email vérifiée"); return; } await finishLogin(req, res, { provider: "google", providerUserId: userInfo.sub, email: userInfo.email, name: userInfo.name, }); } catch (error) { console.error("[OAuth] Google callback failed:", error); redirectToLogin(res, "Connexion Google impossible pour le moment"); } }); app.get("/auth/facebook/start", (req, res) => { if (!isFacebookConfigured()) { redirectToLogin(res, "Connexion Facebook non configurée"); return; } const state = createOAuthState(); setOAuthStateCookie(req, res, FACEBOOK_STATE_COOKIE, state); const params = new URLSearchParams({ client_id: ENV.facebookAppId, redirect_uri: getCallbackUrl(req, "facebook"), state, scope: "email,public_profile", }); res.redirect(`https://www.facebook.com/dialog/oauth?${params.toString()}`); }); app.get("/auth/facebook/callback", async (req, res) => { try { if (!isFacebookConfigured()) { redirectToLogin(res, "Connexion Facebook non configurée"); return; } const code = typeof req.query.code === "string" ? req.query.code : ""; const state = typeof req.query.state === "string" ? req.query.state : null; if (!code || !readAndValidateState(req, res, FACEBOOK_STATE_COOKIE, state)) { redirectToLogin(res, "Connexion Facebook invalide ou expirée"); return; } const tokenUrl = new URL("https://graph.facebook.com/oauth/access_token"); tokenUrl.search = new URLSearchParams({ client_id: ENV.facebookAppId, client_secret: ENV.facebookAppSecret, redirect_uri: getCallbackUrl(req, "facebook"), code, }).toString(); const tokenResponse = await fetch(tokenUrl); if (!tokenResponse.ok) { redirectToLogin(res, "Facebook a refusé la connexion"); return; } const tokenData = await tokenResponse.json() as { access_token?: string }; if (!tokenData.access_token) { redirectToLogin(res, "Jeton Facebook manquant"); return; } const profileUrl = new URL("https://graph.facebook.com/me"); profileUrl.search = new URLSearchParams({ fields: "id,name,email", access_token: tokenData.access_token, }).toString(); const profileResponse = await fetch(profileUrl); if (!profileResponse.ok) { redirectToLogin(res, "Impossible de récupérer le profil Facebook"); return; } const profile = await profileResponse.json() as { id?: string; name?: string; email?: string; }; if (!profile.id || !profile.email) { redirectToLogin(res, "Le compte Facebook doit partager une adresse email"); return; } await finishLogin(req, res, { provider: "facebook", providerUserId: profile.id, email: profile.email, name: profile.name, }); } catch (error) { console.error("[OAuth] Facebook callback failed:", error); redirectToLogin(res, "Connexion Facebook impossible pour le moment"); } }); }