178 lines
5.4 KiB
TypeScript
178 lines
5.4 KiB
TypeScript
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<SapB1Session>;
|
|
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<SapB1Session> {
|
|
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<SapB1Session> {
|
|
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<void> {
|
|
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<string, string> {
|
|
const headers = Array.isArray(setCookieHeader) ? setCookieHeader : setCookieHeader ? [setCookieHeader] : [];
|
|
const cookies: Record<string, string> = {};
|
|
|
|
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;
|
|
}
|
|
}
|