Checkpoint: Migration complète vers authentification locale (JWT + bcrypt) : schéma DB (10 tables), routes tRPC (auth, users, etablissements, inventaire, capex, opex, parametres), page de login avec branding Itinova/Santinova, protection des routes, onglets Établissements et Utilisateurs branchés sur tRPC, import inventaire/établissements/utilisateurs via tRPC, 14 tests Vitest passants.
This commit is contained in:
98
server/_core/sdk.ts
Normal file
98
server/_core/sdk.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* SDK Server — Auth locale Itinova
|
||||
* Remplace le flow Manus OAuth par une authentification locale email/password + JWT.
|
||||
* Le cookie de session contient un JWT signé avec JWT_SECRET.
|
||||
*/
|
||||
import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
|
||||
import { ForbiddenError } from "@shared/_core/errors";
|
||||
import { parse as parseCookieHeader } from "cookie";
|
||||
import type { Request } from "express";
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import type { User } from "../../drizzle/schema";
|
||||
import * as db from "../db";
|
||||
import { ENV } from "./env";
|
||||
|
||||
const isNonEmptyString = (value: unknown): value is string =>
|
||||
typeof value === "string" && value.length > 0;
|
||||
|
||||
export type SessionPayload = {
|
||||
userId: number;
|
||||
login: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
/** Result of `sdk.authenticateRequest`. */
|
||||
export type AuthenticatedUser = User & {
|
||||
taskUid?: string;
|
||||
isCron?: boolean;
|
||||
};
|
||||
|
||||
class SDKServer {
|
||||
private getSessionSecret() {
|
||||
const secret = ENV.cookieSecret;
|
||||
return new TextEncoder().encode(secret);
|
||||
}
|
||||
|
||||
async createSessionToken(
|
||||
userId: number,
|
||||
login: string,
|
||||
role: string,
|
||||
options: { expiresInMs?: number } = {}
|
||||
): Promise<string> {
|
||||
const issuedAt = Date.now();
|
||||
const expiresInMs = options.expiresInMs ?? ONE_YEAR_MS;
|
||||
const expirationSeconds = Math.floor((issuedAt + expiresInMs) / 1000);
|
||||
const secretKey = this.getSessionSecret();
|
||||
|
||||
return new SignJWT({ userId, login, role })
|
||||
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
||||
.setExpirationTime(expirationSeconds)
|
||||
.sign(secretKey);
|
||||
}
|
||||
|
||||
async verifySession(
|
||||
cookieValue: string | undefined | null
|
||||
): Promise<SessionPayload | null> {
|
||||
if (!cookieValue) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const secretKey = this.getSessionSecret();
|
||||
const { payload } = await jwtVerify(cookieValue, secretKey, {
|
||||
algorithms: ["HS256"],
|
||||
});
|
||||
const { userId, login, role } = payload as Record<string, unknown>;
|
||||
if (!userId || !isNonEmptyString(login) || !isNonEmptyString(role)) {
|
||||
return null;
|
||||
}
|
||||
return { userId: userId as number, login, role };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private parseCookies(cookieHeader: string | undefined) {
|
||||
if (!cookieHeader) return new Map<string, string>();
|
||||
const parsed = parseCookieHeader(cookieHeader);
|
||||
return new Map(Object.entries(parsed));
|
||||
}
|
||||
|
||||
async authenticateRequest(req: Request): Promise<AuthenticatedUser> {
|
||||
const cookies = this.parseCookies(req.headers.cookie);
|
||||
const sessionCookie = cookies.get(COOKIE_NAME);
|
||||
const session = await this.verifySession(sessionCookie);
|
||||
|
||||
if (!session) {
|
||||
throw ForbiddenError("Invalid session cookie");
|
||||
}
|
||||
|
||||
const user = await db.getUserById(session.userId);
|
||||
if (!user || !user.isActive) {
|
||||
throw ForbiddenError("User not found or inactive");
|
||||
}
|
||||
|
||||
return user as AuthenticatedUser;
|
||||
}
|
||||
}
|
||||
|
||||
export const sdk = new SDKServer();
|
||||
Reference in New Issue
Block a user