portail-associations/server/socialAuth.ts

281 lines
8.6 KiB
TypeScript

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<string, string>();
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");
}
});
}