Files
polstrin-sap/src/auth/SessionManager.ts
T
JiriUhlir fea25d7429 first
2026-07-15 09:03:28 +02:00

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;
}
}