import "../client/axiosTypes"; import { AxiosInstance } from "axios"; import { z } from "zod"; import { SapB1Config } from "../config"; import { SapB1Error, toSapB1Error } from "../errors/SapB1Error"; import { SapB1Logger, noopLogger, sanitizeLogMeta } from "../utils/logger"; const LoginResponseSchema = z.object({ SessionId: z.string().optional(), Version: z.string().optional(), SessionTimeout: z.number().optional() }); export interface SapB1Session { b1session: string; routeId?: string; expiresAt?: number; } export interface SapB1LoginInfo { version?: string; sessionTimeoutMinutes?: number; } export class SessionManager { private session?: SapB1Session; private loginPromise?: Promise; private lastLoginInfo?: SapB1LoginInfo; public constructor( private readonly http: AxiosInstance, private readonly config: SapB1Config, private readonly logger: SapB1Logger = noopLogger ) {} public get currentSession(): SapB1Session | undefined { return this.session; } // Survives logout on purpose: the login/logout roundtrip in the HTTP layer still // needs to report which Service Layer version answered. public get loginInfo(): SapB1LoginInfo | undefined { return this.lastLoginInfo; } public async getSession(forceRefresh = false): Promise { if (!forceRefresh && this.session && !this.isExpired(this.session)) { return this.session; } if (!this.loginPromise) { this.loginPromise = this.login().finally(() => { this.loginPromise = undefined; }); } return this.loginPromise; } public async login(): Promise { this.logger.info("SAP B1 login request", sanitizeLogMeta({ baseUrl: this.config.baseUrl, companyDB: this.config.companyDB })); try { const response = await this.http.post( "/Login", { CompanyDB: this.config.companyDB, UserName: this.config.username, Password: this.config.password, // Service Layer expects Language as an integer language code (Edm.Int32). Language: this.normalizeLanguage(this.config.language) }, { skipAuth: true } ); const loginData = LoginResponseSchema.parse(response.data); const cookies = this.extractCookies(response.headers["set-cookie"]); const b1session = cookies.B1SESSION || loginData.SessionId; if (!b1session) { throw new SapB1Error({ message: "SAP B1 login did not return B1SESSION cookie or SessionId" }); } const timeoutMinutes = typeof loginData.SessionTimeout === "number" ? loginData.SessionTimeout : 30; this.lastLoginInfo = { version: loginData.Version, sessionTimeoutMinutes: loginData.SessionTimeout }; this.session = { b1session, routeId: cookies.ROUTEID, expiresAt: Date.now() + Math.max(timeoutMinutes - 1, 1) * 60_000 }; return this.session; } catch (error) { // Route login failures through the shared normalizer so the SAP status/code and // rejection message survive into the response. That is what lets the caller tell a // SAP-side rejection (wrong CompanyDB/user/password -> HTTP 401/400 with a SAP body) // apart from a transport problem (wrong BaseUrl/port, TLS, timeout -> no HTTP status). const sapError = toSapB1Error(error, "POST", "/Login", "SAP B1 login failed"); this.logger.error( "SAP B1 login failed", sanitizeLogMeta({ baseUrl: this.config.baseUrl, companyDB: this.config.companyDB, status: sapError.status, code: sapError.code, message: sapError.message }) ); throw sapError; } } public async logout(): Promise { if (!this.session) { return; } const cookieHeader = this.buildCookieHeader(this.session); try { // Logout must carry the active session cookie, otherwise the Service Layer // cannot identify which session to terminate and leaves it open until timeout. await this.http.post("/Logout", undefined, { skipAuth: true, headers: { Cookie: cookieHeader } }); } finally { this.session = undefined; } } public buildCookieHeader(session: SapB1Session): string { const parts = [`B1SESSION=${session.b1session}`]; if (session.routeId) { parts.push(`ROUTEID=${session.routeId}`); } return parts.join("; "); } public clearSession(): void { this.session = undefined; } private normalizeLanguage(language: string | undefined): number | undefined { if (language === undefined || language === "") { return undefined; } const numeric = Number(language); return Number.isInteger(numeric) ? numeric : undefined; } private isExpired(session: SapB1Session): boolean { return session.expiresAt !== undefined && session.expiresAt <= Date.now(); } private extractCookies(setCookieHeader: string[] | string | undefined): Record { const headers = Array.isArray(setCookieHeader) ? setCookieHeader : setCookieHeader ? [setCookieHeader] : []; const cookies: Record = {}; for (const header of headers) { const [nameValue] = header.split(";"); const separatorIndex = nameValue.indexOf("="); if (separatorIndex > 0) { cookies[nameValue.slice(0, separatorIndex)] = nameValue.slice(separatorIndex + 1); } } return cookies; } }