forked from Yara724/api
624 lines
19 KiB
TypeScript
624 lines
19 KiB
TypeScript
import { HttpService } from "@nestjs/axios";
|
|
import {
|
|
BadGatewayException,
|
|
HttpException,
|
|
Injectable,
|
|
Logger,
|
|
ServiceUnavailableException,
|
|
} from "@nestjs/common";
|
|
import { InjectModel } from "@nestjs/mongoose";
|
|
import { isAxiosError } from "axios";
|
|
import { Model } from "mongoose";
|
|
import { firstValueFrom } from "rxjs";
|
|
import {
|
|
getFanavaranClientProfile,
|
|
type FanavaranAuthConfig,
|
|
type FanavaranClientKey,
|
|
} from "src/core/config/fanavaran-client.config";
|
|
import { FanavaranAuditService } from "./fanavaran-audit.service";
|
|
import type { FanavaranAuditSession } from "./fanavaran-audit.types";
|
|
import {
|
|
FanavaranAuditStatus,
|
|
FanavaranAuditStep,
|
|
} from "./schema/fanavaran-audit-log.schema";
|
|
import {
|
|
FanavaranAuthToken,
|
|
FanavaranAuthTokenDocument,
|
|
} from "./schema/fanavaran-auth-token.schema";
|
|
|
|
interface CachedFanavaranAuth {
|
|
authenticationToken: string;
|
|
/** Epoch ms when the cached token should be refreshed. */
|
|
expiresAt: number;
|
|
/** Hash of auth fields used when this token was obtained. */
|
|
authFingerprint: string;
|
|
}
|
|
|
|
interface TenantBackoffState {
|
|
until: number;
|
|
reason: string;
|
|
}
|
|
|
|
/** Shared Fanavaran auth: one AppToken+Login per tenant, reused until TTL / backoff. */
|
|
@Injectable()
|
|
export class FanavaranAuthService {
|
|
private readonly logger = new Logger(FanavaranAuthService.name);
|
|
|
|
private readonly getAppTokenUrl =
|
|
"https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/GetAppToken";
|
|
private readonly loginUrl =
|
|
"https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/Login";
|
|
|
|
/** When Fanavaran says "try again later", pause all tenant calls. */
|
|
static readonly TRANSIENT_BACKOFF_MS = 5 * 60 * 1000;
|
|
|
|
/**
|
|
* Fanavaran authenticationToken is valid until local midnight (Asia/Tehran).
|
|
* First call after 00:00 gets a fresh token; daytime calls reuse the cache.
|
|
*/
|
|
static readonly TOKEN_TIME_ZONE = "Asia/Tehran";
|
|
|
|
private readonly tokenCache = new Map<FanavaranClientKey, CachedFanavaranAuth>();
|
|
private readonly inflightLogin = new Map<
|
|
FanavaranClientKey,
|
|
Promise<string>
|
|
>();
|
|
private readonly backoffByTenant = new Map<
|
|
FanavaranClientKey,
|
|
TenantBackoffState
|
|
>();
|
|
|
|
constructor(
|
|
private readonly httpService: HttpService,
|
|
private readonly fanavaranAuditService: FanavaranAuditService,
|
|
@InjectModel(FanavaranAuthToken.name)
|
|
private readonly authTokenModel: Model<FanavaranAuthTokenDocument>,
|
|
) {}
|
|
|
|
/** True when Fanavaran asked us to wait (Persian “try again later” / tracking-code 500). */
|
|
static isTransientTryLaterError(errorOrMessage: unknown): boolean {
|
|
const message = FanavaranAuthService.extractMessage(errorOrMessage);
|
|
if (!message) return false;
|
|
return (
|
|
message.includes("لطفا پس از چند لحظه مجدد تلاش") ||
|
|
message.includes("مجدد تلاش فرمایید") ||
|
|
/try again later/i.test(message)
|
|
);
|
|
}
|
|
|
|
static extractMessage(errorOrMessage: unknown): string {
|
|
if (typeof errorOrMessage === "string") return errorOrMessage;
|
|
if (isAxiosError(errorOrMessage)) {
|
|
const data = errorOrMessage.response?.data as
|
|
| { Message?: string; message?: string }
|
|
| string
|
|
| undefined;
|
|
if (typeof data === "string") return data;
|
|
return (
|
|
data?.Message ||
|
|
data?.message ||
|
|
errorOrMessage.message ||
|
|
""
|
|
);
|
|
}
|
|
if (errorOrMessage instanceof Error) return errorOrMessage.message;
|
|
return errorOrMessage == null ? "" : String(errorOrMessage);
|
|
}
|
|
|
|
/**
|
|
* Epoch ms of the next 00:00:00 in Asia/Tehran after `now`.
|
|
* If `now` is exactly midnight Tehran, returns the following midnight.
|
|
*/
|
|
static getNextMidnightExpiryMs(
|
|
nowMs: number = Date.now(),
|
|
timeZone: string = FanavaranAuthService.TOKEN_TIME_ZONE,
|
|
): number {
|
|
const parts = Object.fromEntries(
|
|
new Intl.DateTimeFormat("en-US", {
|
|
timeZone,
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
hourCycle: "h23",
|
|
})
|
|
.formatToParts(new Date(nowMs))
|
|
.filter((p) => p.type !== "literal")
|
|
.map((p) => [p.type, p.value]),
|
|
) as Record<string, string>;
|
|
|
|
const hour = Number(parts.hour);
|
|
const minute = Number(parts.minute);
|
|
const second = Number(parts.second);
|
|
const msIntoDay = ((hour * 60 + minute) * 60 + second) * 1000;
|
|
const msPerDay = 24 * 60 * 60 * 1000;
|
|
const remaining = msPerDay - msIntoDay;
|
|
// Exactly at midnight → treat as expired for current day; expire at next midnight.
|
|
return nowMs + (remaining === 0 ? msPerDay : remaining);
|
|
}
|
|
|
|
getBackoffRemainingMs(clientKey: FanavaranClientKey): number {
|
|
const state = this.backoffByTenant.get(clientKey);
|
|
if (!state) return 0;
|
|
return Math.max(0, state.until - Date.now());
|
|
}
|
|
|
|
isInBackoff(clientKey: FanavaranClientKey): boolean {
|
|
return this.getBackoffRemainingMs(clientKey) > 0;
|
|
}
|
|
|
|
/**
|
|
* Call after any Fanavaran HTTP failure. Sets a tenant-wide pause when the
|
|
* error is the familiar “try again later” overload response.
|
|
*/
|
|
registerFailure(clientKey: FanavaranClientKey, error: unknown): void {
|
|
if (!FanavaranAuthService.isTransientTryLaterError(error)) return;
|
|
|
|
const until = Date.now() + FanavaranAuthService.TRANSIENT_BACKOFF_MS;
|
|
const reason = FanavaranAuthService.extractMessage(error).slice(0, 500);
|
|
this.backoffByTenant.set(clientKey, { until, reason });
|
|
// Token may still be valid, but Fanavaran is rejecting work — keep token,
|
|
// just stop hammering Login/business APIs.
|
|
this.logger.warn(
|
|
`[${clientKey}] Fanavaran transient backoff until ${new Date(until).toISOString()}: ${reason}`,
|
|
);
|
|
}
|
|
|
|
clearBackoff(clientKey: FanavaranClientKey): void {
|
|
this.backoffByTenant.delete(clientKey);
|
|
}
|
|
|
|
invalidateToken(clientKey: FanavaranClientKey): void {
|
|
this.tokenCache.delete(clientKey);
|
|
void this.authTokenModel.deleteOne({ clientKey }).exec().catch((error) => {
|
|
this.logger.warn(
|
|
`[${clientKey}] Failed to clear persisted Fanavaran auth token`,
|
|
error,
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Stable fingerprint of tenant auth. Any change (location, credentials, …)
|
|
* forces a fresh GetAppToken+Login even before Tehran midnight.
|
|
*/
|
|
static authFingerprint(auth: FanavaranAuthConfig): string {
|
|
return [
|
|
auth.appName,
|
|
auth.secret,
|
|
auth.username,
|
|
auth.password,
|
|
auth.corpId,
|
|
auth.contractId,
|
|
auth.location,
|
|
].join("|");
|
|
}
|
|
|
|
assertNotInBackoff(clientKey: FanavaranClientKey): void {
|
|
const remaining = this.getBackoffRemainingMs(clientKey);
|
|
if (remaining <= 0) return;
|
|
const state = this.backoffByTenant.get(clientKey);
|
|
throw new ServiceUnavailableException(
|
|
`Fanavaran tenant "${clientKey}" is in backoff for ${Math.ceil(remaining / 1000)}s after transient errors. ${state?.reason ?? ""}`.trim(),
|
|
);
|
|
}
|
|
|
|
async getAuthenticationToken(
|
|
clientKey: FanavaranClientKey,
|
|
options?: {
|
|
auditSession?: FanavaranAuditSession;
|
|
forceRefresh?: boolean;
|
|
},
|
|
): Promise<string> {
|
|
this.assertNotInBackoff(clientKey);
|
|
const fingerprint = FanavaranAuthService.authFingerprint(
|
|
getFanavaranClientProfile(clientKey).auth,
|
|
);
|
|
|
|
if (!options?.forceRefresh) {
|
|
const memoryHit = this.readMemoryCache(clientKey, fingerprint);
|
|
if (memoryHit) {
|
|
return memoryHit;
|
|
}
|
|
const persisted = await this.readPersistedCache(clientKey, fingerprint);
|
|
if (persisted) {
|
|
return persisted;
|
|
}
|
|
} else {
|
|
this.invalidateToken(clientKey);
|
|
}
|
|
|
|
const existing = this.inflightLogin.get(clientKey);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
const loginPromise = this.loginFresh(clientKey, options?.auditSession);
|
|
this.inflightLogin.set(clientKey, loginPromise);
|
|
try {
|
|
return await loginPromise;
|
|
} finally {
|
|
this.inflightLogin.delete(clientKey);
|
|
}
|
|
}
|
|
|
|
async getRequestHeaders(
|
|
clientKey: FanavaranClientKey,
|
|
options?: {
|
|
auditSession?: FanavaranAuditSession;
|
|
forceRefresh?: boolean;
|
|
/** Per-request Location override (does not affect token fingerprint). */
|
|
locationOverride?: string;
|
|
},
|
|
): Promise<{
|
|
authenticationToken: string;
|
|
CorpId: string;
|
|
ContractId: string;
|
|
Location: string;
|
|
}> {
|
|
const profile = getFanavaranClientProfile(clientKey);
|
|
const authenticationToken = await this.getAuthenticationToken(
|
|
clientKey,
|
|
options,
|
|
);
|
|
const locationOverride = options?.locationOverride?.trim();
|
|
return {
|
|
authenticationToken,
|
|
CorpId: profile.auth.corpId,
|
|
ContractId: profile.auth.contractId,
|
|
Location: locationOverride || profile.auth.location,
|
|
};
|
|
}
|
|
|
|
private async loginFresh(
|
|
clientKey: FanavaranClientKey,
|
|
auditSession?: FanavaranAuditSession,
|
|
): Promise<string> {
|
|
const profile = getFanavaranClientProfile(clientKey);
|
|
const fingerprint = FanavaranAuthService.authFingerprint(profile.auth);
|
|
const appToken = await this.fetchAppToken(profile.auth, auditSession);
|
|
const authenticationToken = await this.fetchLoginToken(
|
|
appToken,
|
|
profile.auth,
|
|
auditSession,
|
|
);
|
|
|
|
const expiresAt = FanavaranAuthService.getNextMidnightExpiryMs();
|
|
await this.persistToken(
|
|
clientKey,
|
|
authenticationToken,
|
|
expiresAt,
|
|
fingerprint,
|
|
);
|
|
this.clearBackoff(clientKey);
|
|
this.logger.log(
|
|
`[${clientKey}] Cached Fanavaran authenticationToken until ${new Date(
|
|
expiresAt,
|
|
).toISOString()} (${FanavaranAuthService.TOKEN_TIME_ZONE} midnight)`,
|
|
);
|
|
return authenticationToken;
|
|
}
|
|
|
|
private readMemoryCache(
|
|
clientKey: FanavaranClientKey,
|
|
fingerprint: string,
|
|
): string | null {
|
|
const cached = this.tokenCache.get(clientKey);
|
|
if (!cached) {
|
|
return null;
|
|
}
|
|
if (cached.authFingerprint !== fingerprint) {
|
|
this.logger.log(
|
|
`[${clientKey}] Auth config changed — discarding in-memory Fanavaran token`,
|
|
);
|
|
this.tokenCache.delete(clientKey);
|
|
return null;
|
|
}
|
|
if (cached.expiresAt > Date.now()) {
|
|
return cached.authenticationToken;
|
|
}
|
|
this.tokenCache.delete(clientKey);
|
|
return null;
|
|
}
|
|
|
|
private async readPersistedCache(
|
|
clientKey: FanavaranClientKey,
|
|
fingerprint: string,
|
|
): Promise<string | null> {
|
|
try {
|
|
const doc = await this.authTokenModel.findOne({ clientKey }).lean().exec();
|
|
if (!doc?.authenticationToken || !doc.expiresAt) {
|
|
return null;
|
|
}
|
|
if (!doc.authFingerprint || doc.authFingerprint !== fingerprint) {
|
|
this.logger.log(
|
|
`[${clientKey}] Auth config changed (or legacy token without fingerprint) — discarding persisted Fanavaran token`,
|
|
);
|
|
await this.authTokenModel.deleteOne({ clientKey }).exec();
|
|
return null;
|
|
}
|
|
const expiresAt = new Date(doc.expiresAt).getTime();
|
|
if (!(expiresAt > Date.now())) {
|
|
await this.authTokenModel.deleteOne({ clientKey }).exec();
|
|
return null;
|
|
}
|
|
this.tokenCache.set(clientKey, {
|
|
authenticationToken: doc.authenticationToken,
|
|
expiresAt,
|
|
authFingerprint: doc.authFingerprint,
|
|
});
|
|
this.logger.log(
|
|
`[${clientKey}] Reused persisted Fanavaran authenticationToken until ${new Date(
|
|
expiresAt,
|
|
).toISOString()}`,
|
|
);
|
|
return doc.authenticationToken;
|
|
} catch (error) {
|
|
this.logger.warn(
|
|
`[${clientKey}] Failed to read persisted Fanavaran auth token`,
|
|
error,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async persistToken(
|
|
clientKey: FanavaranClientKey,
|
|
authenticationToken: string,
|
|
expiresAt: number,
|
|
authFingerprint: string,
|
|
): Promise<void> {
|
|
this.tokenCache.set(clientKey, {
|
|
authenticationToken,
|
|
expiresAt,
|
|
authFingerprint,
|
|
});
|
|
try {
|
|
await this.authTokenModel
|
|
.findOneAndUpdate(
|
|
{ clientKey },
|
|
{
|
|
$set: {
|
|
authenticationToken,
|
|
expiresAt: new Date(expiresAt),
|
|
authFingerprint,
|
|
},
|
|
},
|
|
{ upsert: true, new: true },
|
|
)
|
|
.exec();
|
|
} catch (error) {
|
|
this.logger.warn(
|
|
`[${clientKey}] Failed to persist Fanavaran auth token (memory cache still active)`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
private emptyBodyTransformRequest() {
|
|
return [
|
|
(_data: unknown, headers?: Record<string, unknown>) => {
|
|
if (headers) {
|
|
delete headers["Content-Type"];
|
|
delete headers["content-type"];
|
|
}
|
|
return _data;
|
|
},
|
|
];
|
|
}
|
|
|
|
private async fetchAppToken(
|
|
config: Pick<FanavaranAuthConfig, "appName" | "secret">,
|
|
auditSession?: FanavaranAuditSession,
|
|
): Promise<string> {
|
|
const startedAt = Date.now();
|
|
const requestHeaders = {
|
|
appname: config.appName,
|
|
secret: config.secret,
|
|
"Content-Length": "0",
|
|
};
|
|
if (auditSession) {
|
|
await this.fanavaranAuditService.recordStep({
|
|
session: auditSession,
|
|
step: FanavaranAuditStep.GET_APP_TOKEN,
|
|
status: FanavaranAuditStatus.STARTED,
|
|
requestUrl: this.getAppTokenUrl,
|
|
requestMethod: "POST",
|
|
requestHeaders,
|
|
requestBody: "",
|
|
requestMeta: { appName: config.appName, cached: false },
|
|
});
|
|
}
|
|
|
|
try {
|
|
const response = await firstValueFrom(
|
|
this.httpService.post(this.getAppTokenUrl, "", {
|
|
headers: requestHeaders,
|
|
transformRequest: this.emptyBodyTransformRequest(),
|
|
}),
|
|
);
|
|
|
|
const appToken =
|
|
response.headers.apptoken ||
|
|
response.headers.appToken ||
|
|
response.headers["apptoken"] ||
|
|
response.headers["appToken"];
|
|
|
|
if (!appToken) {
|
|
throw new BadGatewayException("Failed to get Fanavaran appToken");
|
|
}
|
|
|
|
if (auditSession) {
|
|
const exchange =
|
|
this.fanavaranAuditService.captureAxiosExchange(
|
|
response,
|
|
requestHeaders,
|
|
);
|
|
await this.fanavaranAuditService.recordStep({
|
|
session: auditSession,
|
|
step: FanavaranAuditStep.GET_APP_TOKEN,
|
|
status: FanavaranAuditStatus.SUCCESS,
|
|
requestUrl: this.getAppTokenUrl,
|
|
requestMethod: "POST",
|
|
httpStatus: response.status,
|
|
requestHeaders: exchange.requestHeaders,
|
|
requestBody: "",
|
|
responseHeaders: exchange.responseHeaders,
|
|
responseBody: exchange.responseBody,
|
|
responseMeta: { hasAppToken: true, cached: false },
|
|
durationMs: Date.now() - startedAt,
|
|
});
|
|
}
|
|
|
|
return appToken;
|
|
} catch (error) {
|
|
if (auditSession) {
|
|
const exchange = this.fanavaranAuditService.captureAxiosExchange(
|
|
error,
|
|
requestHeaders,
|
|
);
|
|
await this.fanavaranAuditService.recordStep({
|
|
session: auditSession,
|
|
step: FanavaranAuditStep.GET_APP_TOKEN,
|
|
status: FanavaranAuditStatus.FAILURE,
|
|
requestUrl: this.getAppTokenUrl,
|
|
requestMethod: "POST",
|
|
httpStatus: exchange.httpStatus,
|
|
requestHeaders: exchange.requestHeaders,
|
|
requestBody: "",
|
|
responseHeaders: exchange.responseHeaders,
|
|
responseBody: exchange.responseBody,
|
|
errorMessage: this.fanavaranAuditService.extractErrorMessage(error),
|
|
errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error),
|
|
durationMs: Date.now() - startedAt,
|
|
});
|
|
}
|
|
throw this.toGatewayError(error, "Failed to get appToken from external API", auditSession);
|
|
}
|
|
}
|
|
|
|
private async fetchLoginToken(
|
|
appToken: string,
|
|
config: Pick<FanavaranAuthConfig, "username" | "password">,
|
|
auditSession?: FanavaranAuditSession,
|
|
): Promise<string> {
|
|
const startedAt = Date.now();
|
|
const requestHeaders = {
|
|
appToken,
|
|
userName: config.username,
|
|
password: config.password,
|
|
"Content-Length": "0",
|
|
};
|
|
if (auditSession) {
|
|
await this.fanavaranAuditService.recordStep({
|
|
session: auditSession,
|
|
step: FanavaranAuditStep.LOGIN,
|
|
status: FanavaranAuditStatus.STARTED,
|
|
requestUrl: this.loginUrl,
|
|
requestMethod: "POST",
|
|
requestHeaders,
|
|
requestBody: "",
|
|
requestMeta: { userName: config.username, cached: false },
|
|
});
|
|
}
|
|
|
|
try {
|
|
const response = await firstValueFrom(
|
|
this.httpService.post(this.loginUrl, "", {
|
|
headers: requestHeaders,
|
|
transformRequest: this.emptyBodyTransformRequest(),
|
|
}),
|
|
);
|
|
|
|
const authenticationToken =
|
|
response.headers.authenticationtoken ||
|
|
response.headers.authenticationToken ||
|
|
response.headers["authenticationtoken"] ||
|
|
response.headers["authenticationToken"] ||
|
|
response.data?.authenticationtoken ||
|
|
response.data?.authenticationToken ||
|
|
response.data?.authentication_token;
|
|
|
|
if (!authenticationToken) {
|
|
throw new BadGatewayException(
|
|
"Failed to get Fanavaran authenticationToken",
|
|
);
|
|
}
|
|
|
|
if (auditSession) {
|
|
const exchange =
|
|
this.fanavaranAuditService.captureAxiosExchange(
|
|
response,
|
|
requestHeaders,
|
|
);
|
|
await this.fanavaranAuditService.recordStep({
|
|
session: auditSession,
|
|
step: FanavaranAuditStep.LOGIN,
|
|
status: FanavaranAuditStatus.SUCCESS,
|
|
requestUrl: this.loginUrl,
|
|
requestMethod: "POST",
|
|
httpStatus: response.status,
|
|
requestHeaders: exchange.requestHeaders,
|
|
requestBody: "",
|
|
responseHeaders: exchange.responseHeaders,
|
|
responseBody: exchange.responseBody,
|
|
responseMeta: {
|
|
hasAuthenticationToken: true,
|
|
cached: false,
|
|
},
|
|
durationMs: Date.now() - startedAt,
|
|
});
|
|
}
|
|
|
|
return authenticationToken;
|
|
} catch (error) {
|
|
if (auditSession) {
|
|
const exchange = this.fanavaranAuditService.captureAxiosExchange(
|
|
error,
|
|
requestHeaders,
|
|
);
|
|
await this.fanavaranAuditService.recordStep({
|
|
session: auditSession,
|
|
step: FanavaranAuditStep.LOGIN,
|
|
status: FanavaranAuditStatus.FAILURE,
|
|
requestUrl: this.loginUrl,
|
|
requestMethod: "POST",
|
|
httpStatus: exchange.httpStatus,
|
|
requestHeaders: exchange.requestHeaders,
|
|
requestBody: "",
|
|
responseHeaders: exchange.responseHeaders,
|
|
responseBody: exchange.responseBody,
|
|
errorMessage: this.fanavaranAuditService.extractErrorMessage(error),
|
|
errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error),
|
|
durationMs: Date.now() - startedAt,
|
|
});
|
|
}
|
|
throw this.toGatewayError(error, "Failed to login to external API", auditSession);
|
|
}
|
|
}
|
|
|
|
private toGatewayError(
|
|
error: unknown,
|
|
fallback: string,
|
|
auditSession?: FanavaranAuditSession,
|
|
): HttpException {
|
|
if (error instanceof HttpException) {
|
|
return error;
|
|
}
|
|
const message = isAxiosError(error)
|
|
? error.response?.data?.Message ||
|
|
error.response?.data?.message ||
|
|
error.message ||
|
|
fallback
|
|
: fallback;
|
|
return new BadGatewayException(
|
|
this.fanavaranAuditService.formatErrorWithTrackingCode(
|
|
String(message),
|
|
auditSession?.trackingCode,
|
|
),
|
|
);
|
|
}
|
|
}
|