Checkpoint: Connexion Microsoft 365 (Azure AD OAuth2) : migration DB azureAdId, helpers backend, route callback, bouton Login, page AzureCallback, tests OK

This commit is contained in:
Manus
2026-07-07 10:29:03 -04:00
parent 2d0de2a30d
commit 465e33c3b8
16 changed files with 1529 additions and 5 deletions

View File

@@ -11,8 +11,9 @@ import { serveStatic, setupVite } from "./vite";
import { runFullImport } from "../importer";
import uploadRoutes from "../uploadRoutes";
import scheduledRoutes from "../scheduledRoutes";
import { ensureAdminExists } from "../localAuth";
import { getSetting, purgeOldArticles } from "../db";
import { ensureAdminExists, generateLocalToken } from "../localAuth";
import { isAzureAdConfigured, getAzureAuthUrl, handleAzureCallback } from "../azureAuth";
import { getLocalUserByAzureAdId, getLocalUserByEmail, upsertLocalUserAzure, getSetting, purgeOldArticles } from "../db";
import { runRssFetch } from "../rssEngine";
function isPortAvailable(port: number): Promise<boolean> {
@@ -126,6 +127,87 @@ async function startServer() {
registerOAuthRoutes(app);
app.use(uploadRoutes);
app.use(scheduledRoutes);
// ─── Azure AD OAuth2 callback ─────────────────────────────────────────────
app.get("/api/auth/azure/callback", async (req, res) => {
const code = req.query.code as string | undefined;
const error = req.query.error as string | undefined;
if (error) {
res.redirect(`/login?error=${encodeURIComponent("Connexion Microsoft refus\u00e9e")}`);
return;
}
if (!code) {
res.redirect("/login?error=" + encodeURIComponent("Code OAuth manquant"));
return;
}
if (!isAzureAdConfigured()) {
res.redirect("/login?error=" + encodeURIComponent("Azure AD non configur\u00e9"));
return;
}
try {
const azureUser = await handleAzureCallback(code);
// Chercher par azureAdId puis par email
let user = await getLocalUserByAzureAdId(azureUser.azureAdId);
if (!user) user = await getLocalUserByEmail(azureUser.email);
if (!user) {
// Cr\u00e9er automatiquement avec r\u00f4le "user"
await upsertLocalUserAzure({
email: azureUser.email,
name: azureUser.name,
azureAdId: azureUser.azureAdId,
role: "user",
});
user = await getLocalUserByEmail(azureUser.email);
} else if (!user.azureAdId) {
// Lier le compte existant \u00e0 Azure AD
await upsertLocalUserAzure({
email: user.email ?? azureUser.email,
azureAdId: azureUser.azureAdId,
});
}
if (!user || !user.isActive) {
res.redirect("/login?error=" + encodeURIComponent("Compte inactif ou introuvable"));
return;
}
// Mettre \u00e0 jour lastSignedIn
const db = await (await import("../db")).getDb();
if (db) {
const { localUsers } = await import("../../drizzle/schema");
const { eq } = await import("drizzle-orm");
await db.update(localUsers).set({ lastSignedIn: new Date() }).where(eq(localUsers.id, user.id));
}
// G\u00e9n\u00e9rer le token JWT local et le stocker dans le cookie
const token = await generateLocalToken(user.id, user.role);
res.cookie("veille_local_auth", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 7 * 24 * 60 * 60 * 1000,
});
// Retourner les infos user en JSON pour que le frontend hydrate LocalAuthContext
const userPayload = JSON.stringify({
id: user.id,
name: user.name,
username: user.username ?? null,
email: user.email ?? null,
role: user.role,
});
// Rediriger vers une page de callback qui hydrate le contexte
res.redirect(`/azure-callback?user=${encodeURIComponent(userPayload)}`);
} catch (err: any) {
console.error("[Azure AD] Erreur callback:", err.message);
res.redirect("/login?error=" + encodeURIComponent("Erreur d'authentification Microsoft"));
}
});
app.use(
"/api/trpc",
createExpressMiddleware({ router: appRouter, createContext })

21
server/azureAuth.test.ts Normal file
View File

@@ -0,0 +1,21 @@
import { describe, it, expect } from "vitest";
import { isAzureAdConfigured, getAzureAuthUrl } from "./azureAuth";
describe("Azure AD configuration", () => {
it("should detect Azure AD as configured when env vars are set", () => {
// Les variables sont injectées via webdev_request_secrets
const configured = isAzureAdConfigured();
expect(configured).toBe(true);
});
it("should generate a valid Azure AD auth URL", async () => {
if (!isAzureAdConfigured()) {
console.warn("Azure AD not configured, skipping URL test");
return;
}
const url = await getAzureAuthUrl();
expect(url).toContain("login.microsoftonline.com");
expect(url).toContain("oauth2/v2.0/authorize");
expect(url).toContain("f496da82-e18f-4567-bf05-8551ae6669b2"); // client_id
});
});

76
server/azureAuth.ts Normal file
View File

@@ -0,0 +1,76 @@
import { ConfidentialClientApplication } from "@azure/msal-node";
// ─── Azure AD Authentication ──────────────────────────────────────────────────
let msalClient: ConfidentialClientApplication | null = null;
/**
* Vérifie que les 3 variables d'environnement Azure AD sont présentes
*/
export function isAzureAdConfigured(): boolean {
return !!(
process.env.AZURE_AD_TENANT_ID &&
process.env.AZURE_AD_CLIENT_ID &&
process.env.AZURE_AD_CLIENT_SECRET
);
}
/**
* Instancie le client MSAL (lazy, singleton)
*/
function getMsalClient(): ConfidentialClientApplication {
if (!isAzureAdConfigured()) {
throw new Error("Azure AD is not configured");
}
if (!msalClient) {
msalClient = new ConfidentialClientApplication({
auth: {
clientId: process.env.AZURE_AD_CLIENT_ID!,
authority: `https://login.microsoftonline.com/${process.env.AZURE_AD_TENANT_ID}`,
clientSecret: process.env.AZURE_AD_CLIENT_SECRET!,
},
});
}
return msalClient;
}
/**
* Retourne l'URL de redirection Azure AD pour l'utilisateur
*/
export async function getAzureAuthUrl(): Promise<string> {
const client = getMsalClient();
const redirectUri =
process.env.AZURE_AD_REDIRECT_URI ||
"http://localhost:3000/api/auth/azure/callback";
return client.getAuthCodeUrl({
scopes: ["user.read"],
redirectUri,
});
}
/**
* Échange le code OAuth contre un token et retourne les infos utilisateur.
* azureAdId = homeAccountId = "{objectId}.{tenantId}" (~73 caractères)
*/
export async function handleAzureCallback(code: string) {
const client = getMsalClient();
const redirectUri =
process.env.AZURE_AD_REDIRECT_URI ||
"http://localhost:3000/api/auth/azure/callback";
const response = await client.acquireTokenByCode({
code,
scopes: ["user.read"],
redirectUri,
});
if (!response || !response.account) {
throw new Error("Failed to acquire token from Azure AD");
}
return {
azureAdId: response.account.homeAccountId, // "{objectId}.{tenantId}"
email: response.account.username, // UPN (ex: user@domain.com)
name: response.account.name || response.account.username,
};
}

View File

@@ -116,6 +116,51 @@ export async function deleteLocalUser(id: number) {
await db.delete(localUsers).where(eq(localUsers.id, id));
}
export async function getLocalUserByAzureAdId(azureAdId: string) {
const db = await getDb();
if (!db) return null;
const results = await db.select().from(localUsers).where(eq(localUsers.azureAdId, azureAdId)).limit(1);
return results[0] ?? null;
}
export async function getLocalUserByEmail(email: string) {
const db = await getDb();
if (!db) return null;
const results = await db.select().from(localUsers).where(eq(localUsers.email, email)).limit(1);
return results[0] ?? null;
}
export async function upsertLocalUserAzure(data: {
email: string;
name?: string;
azureAdId: string;
role?: "admin" | "user" | "readonly";
}) {
const db = await getDb();
if (!db) throw new Error("DB unavailable");
// Chercher si l'utilisateur existe déjà par email
const existing = await getLocalUserByEmail(data.email);
if (existing) {
// Lier le compte existant à Azure AD
await db.update(localUsers)
.set({ azureAdId: data.azureAdId, ...(data.name && { name: data.name }) })
.where(eq(localUsers.id, existing.id));
return existing.id;
} else {
// Créer un nouveau compte (sans mot de passe — connexion Azure uniquement)
const result = await db.insert(localUsers).values({
name: data.name ?? data.email,
username: data.email,
email: data.email,
passwordHash: "", // Pas de mot de passe local
role: data.role ?? "user",
isActive: true,
azureAdId: data.azureAdId,
});
return (result as any)[0]?.insertId ?? null;
}
}
// ─── Veille Items ─────────────────────────────────────────────────────────────
export interface VeilleFilters {

View File

@@ -35,6 +35,7 @@ import {
import { importVeille, importAAP, runFullImport, getImportConfig } from "./importer";
import { scheduleDailyImport } from "./_core/index";
import { loginLocalUser, hashPassword, ensureAdminExists } from "./localAuth";
import { isAzureAdConfigured, getAzureAuthUrl } from "./azureAuth";
import { classifyArticle } from "./aiClassifier";
import { getDb } from "./db";
import { veilleItems, aapItems, articleReads, processedDedupKeys } from "../drizzle/schema";
@@ -80,6 +81,17 @@ export const appRouter = router({
ctx.res.clearCookie("veille_local_auth", { ...cookieOptions, maxAge: -1 });
return { success: true };
}),
// Azure AD
isAzureAdAvailable: publicProcedure.query(() => {
return { available: isAzureAdConfigured() };
}),
getAzureLoginUrl: publicProcedure.query(async () => {
if (!isAzureAdConfigured()) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Azure AD non configur\u00e9" });
}
const url = await getAzureAuthUrl();
return { url };
}),
}),
// ─── Veille ─────────────────────────────────────────────────────────────────