281 lines
8.6 KiB
TypeScript
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");
|
|
}
|
|
});
|
|
}
|