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

@@ -1,4 +1,4 @@
{
"version": "aa6b358c",
"timestamp": 1783341062192
"version": "f8f8affc",
"timestamp": 1783434543359
}

View File

@@ -14,6 +14,7 @@ import UsersAdmin from "./pages/UsersAdmin";
import ImportLogs from "./pages/ImportLogs";
import BoiteAIdees from "@/pages/BoiteAIdees";
import RssFeeds from "@/pages/RssFeeds";
import AzureCallback from "@/pages/AzureCallback";
import { Loader2 } from "lucide-react";
// ─── Guard d'authentification ─────────────────────────────────────────────────
@@ -125,6 +126,7 @@ function Router() {
return (
<Switch>
<Route path="/login" component={Login} />
<Route path="/azure-callback" component={AzureCallback} />
<Route path="/">
<Redirect to="/veille" />
</Route>

View File

@@ -14,6 +14,7 @@ interface LocalAuthContextType {
loading: boolean;
login: (identifier: string, password: string) => Promise<void>;
logout: () => Promise<void>;
hydrateUser: (user: LocalUser) => void;
isAuthenticated: boolean;
}
@@ -57,6 +58,11 @@ export function LocalAuthProvider({ children }: { children: ReactNode }) {
localStorage.removeItem(LOCAL_USER_KEY);
};
const hydrateUser = (u: LocalUser) => {
setUser(u);
localStorage.setItem(LOCAL_USER_KEY, JSON.stringify(u));
};
return (
<LocalAuthContext.Provider
value={{
@@ -64,6 +70,7 @@ export function LocalAuthProvider({ children }: { children: ReactNode }) {
loading,
login,
logout,
hydrateUser,
isAuthenticated: !!user,
}}
>

View File

@@ -0,0 +1,40 @@
import { useEffect } from "react";
import { useLocation } from "wouter";
import { useLocalAuth } from "@/contexts/LocalAuthContext";
import { Loader2 } from "lucide-react";
/**
* Page intermédiaire appelée après le callback Azure AD.
* Elle récupère les infos user depuis le query param, hydrate LocalAuthContext
* (localStorage + state), puis redirige vers /veille.
*/
export default function AzureCallback() {
const [, navigate] = useLocation();
const { hydrateUser } = useLocalAuth();
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const userParam = params.get("user");
if (userParam) {
try {
const user = JSON.parse(decodeURIComponent(userParam));
hydrateUser(user);
navigate("/veille");
} catch {
navigate("/login?error=" + encodeURIComponent("Erreur lors de la connexion Microsoft"));
}
} else {
navigate("/login?error=" + encodeURIComponent("Réponse Azure invalide"));
}
}, []);
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="flex flex-col items-center gap-4 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin" />
<p className="text-sm">Connexion Microsoft en cours</p>
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useLocation } from "wouter";
import { useLocalAuth } from "@/contexts/LocalAuthContext";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -11,14 +12,40 @@ import { toast } from "sonner";
const ITINOVA_LOGO = "https://d2xsxph8kpxj0f.cloudfront.net/310519663070627318/VepzDyqR8YkJNcqpZ729Bw/itinova-logo_8e653b24.jpg";
const SANTINOVA_LOGO = "https://d2xsxph8kpxj0f.cloudfront.net/310519663070627318/VepzDyqR8YkJNcqpZ729Bw/santinova-logo_b8de54c4.webp";
function MicrosoftLogo() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 21" width="18" height="18">
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
<rect x="11" y="1" width="9" height="9" fill="#7fba00"/>
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
</svg>
);
}
export default function Login() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [azureLoading, setAzureLoading] = useState(false);
const { login } = useLocalAuth();
const [, navigate] = useLocation();
const azureAvailableQuery = trpc.auth.isAzureAdAvailable.useQuery();
const azureLoginQuery = trpc.auth.getAzureLoginUrl.useQuery(undefined, { enabled: false, retry: false });
const azureAvailable = azureAvailableQuery.data?.available ?? false;
// Afficher les erreurs depuis query param (callback Azure)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const error = params.get("error");
if (error) {
toast.error(decodeURIComponent(error));
window.history.replaceState({}, "", "/login");
}
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password) return;
@@ -34,6 +61,22 @@ export default function Login() {
}
};
const handleMicrosoftLogin = async () => {
setAzureLoading(true);
try {
const result = await azureLoginQuery.refetch();
if (result.data?.url) {
window.location.href = result.data.url;
} else {
toast.error("Impossible d'obtenir l'URL de connexion Microsoft");
setAzureLoading(false);
}
} catch {
toast.error("Erreur lors de la connexion Microsoft");
setAzureLoading(false);
}
};
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
{/* Décoration de fond */}
@@ -67,6 +110,34 @@ export default function Login() {
</CardDescription>
</CardHeader>
<CardContent>
{/* Bouton Microsoft 365 — affiché uniquement si Azure AD est configuré */}
{azureAvailable && (
<>
<Button
type="button"
variant="outline"
className="w-full flex items-center gap-3 border-slate-300 bg-white hover:bg-slate-50 text-slate-700 font-medium mb-4"
onClick={handleMicrosoftLogin}
disabled={azureLoading}
>
{azureLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MicrosoftLogo />
)}
Se connecter avec Microsoft 365
</Button>
<div className="relative mb-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-slate-200" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">ou</span>
</div>
</div>
</>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Identifiant ou e-mail</Label>

View File

@@ -0,0 +1,2 @@
ALTER TABLE `local_users` ADD `azureAdId` varchar(128);--> statement-breakpoint
ALTER TABLE `local_users` ADD CONSTRAINT `local_users_azureAdId_unique` UNIQUE(`azureAdId`);

File diff suppressed because it is too large Load Diff

View File

@@ -78,6 +78,13 @@
"when": 1782978530081,
"tag": "0010_graceful_nova",
"breakpoints": true
},
{
"idx": 11,
"version": "5",
"when": 1783432258494,
"tag": "0011_sharp_gambit",
"breakpoints": true
}
]
}

View File

@@ -36,6 +36,7 @@ export const localUsers = mysqlTable("local_users", {
passwordHash: varchar("passwordHash", { length: 255 }).notNull(),
role: mysqlEnum("role", ["admin", "user", "readonly"]).default("user").notNull(),
isActive: boolean("isActive").default(true).notNull(),
azureAdId: varchar("azureAdId", { length: 128 }).unique(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
lastSignedIn: timestamp("lastSignedIn"),

View File

@@ -15,6 +15,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.693.0",
"@aws-sdk/s3-request-presigner": "^3.693.0",
"@azure/msal-node": "^5.3.1",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",

106
pnpm-lock.yaml generated
View File

@@ -22,6 +22,9 @@ importers:
'@aws-sdk/s3-request-presigner':
specifier: ^3.693.0
version: 3.907.0
'@azure/msal-node':
specifier: ^5.3.1
version: 5.3.1
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.64.0(react@19.2.1))
@@ -477,6 +480,14 @@ packages:
resolution: {integrity: sha512-ORHRQ2tmvnBXc8t/X9Z8IcSbBA4xTLKuN873FopzklHMeqBst7YG0d+AX97inkvDX+NChYtSr+qGfcqGFaI8Zw==}
engines: {node: '>=18.0.0'}
'@azure/msal-common@16.11.0':
resolution: {integrity: sha512-UikJOtMwkFpZNzTH6Dqk8UTUPbow15zH3e0UjGYZy69lYENW/S05gMLhbxI2eonz66uALhIljvhsSMEb6+O30g==}
engines: {node: '>=0.8.0'}
'@azure/msal-node@5.3.1':
resolution: {integrity: sha512-sqqv3L1UOI4KDXonNtbxPYUgbSWVXqxvmmb6BUw9n4P/UXgG+cVur3dLWQN4Cz7qQ+UJROCCxMXlksm7gIq0Sw==}
engines: {node: '>=20'}
'@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
@@ -2481,6 +2492,9 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -2968,6 +2982,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -3336,6 +3353,16 @@ packages:
engines: {node: '>=6'}
hasBin: true
jsonwebtoken@9.0.3:
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
engines: {node: '>=12', npm: '>=6'}
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
katex@0.16.25:
resolution: {integrity: sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q==}
hasBin: true
@@ -3427,6 +3454,27 @@ packages:
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
lodash.isboolean@3.0.3:
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
lodash.isinteger@4.0.4:
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
lodash.isnumber@3.0.3:
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.isstring@4.0.1:
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
@@ -4001,6 +4049,11 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
send@0.19.0:
resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==}
engines: {node: '>= 0.8.0'}
@@ -4949,6 +5002,13 @@ snapshots:
'@aws/lambda-invoke-store@0.0.1': {}
'@azure/msal-common@16.11.0': {}
'@azure/msal-node@5.3.1':
dependencies:
'@azure/msal-common': 16.11.0
jsonwebtoken: 9.0.3
'@babel/code-frame@7.27.1':
dependencies:
'@babel/helper-validator-identifier': 7.27.1
@@ -6953,6 +7013,8 @@ snapshots:
node-releases: 2.0.23
update-browserslist-db: 1.1.3(browserslist@4.26.3)
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
busboy@1.6.0:
@@ -7347,6 +7409,10 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
ee-first@1.1.1: {}
electron-to-chromium@1.5.230: {}
@@ -7837,6 +7903,30 @@ snapshots:
json5@2.2.3: {}
jsonwebtoken@9.0.3:
dependencies:
jws: 4.0.1
lodash.includes: 4.3.0
lodash.isboolean: 3.0.3
lodash.isinteger: 4.0.4
lodash.isnumber: 3.0.3
lodash.isplainobject: 4.0.6
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
semver: 7.8.5
jwa@2.0.1:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@4.0.1:
dependencies:
jwa: 2.0.1
safe-buffer: 5.2.1
katex@0.16.25:
dependencies:
commander: 8.3.0
@@ -7910,6 +8000,20 @@ snapshots:
lodash-es@4.17.21: {}
lodash.includes@4.3.0: {}
lodash.isboolean@3.0.3: {}
lodash.isinteger@4.0.4: {}
lodash.isnumber@3.0.3: {}
lodash.isplainobject@4.0.6: {}
lodash.isstring@4.0.1: {}
lodash.once@4.1.1: {}
lodash@4.17.21: {}
long@5.3.2: {}
@@ -8774,6 +8878,8 @@ snapshots:
semver@6.3.1: {}
semver@7.8.5: {}
send@0.19.0:
dependencies:
debug: 2.6.9

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 ─────────────────────────────────────────────────────────────────