first
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user