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:
@@ -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
21
server/azureAuth.test.ts
Normal 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
76
server/azureAuth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
45
server/db.ts
45
server/db.ts
@@ -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 {
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user