99 lines
2.9 KiB
TypeScript
99 lines
2.9 KiB
TypeScript
/**
|
|
* 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();
|