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(); private readonly inflightLogin = new Map< FanavaranClientKey, Promise >(); private readonly backoffByTenant = new Map< FanavaranClientKey, TenantBackoffState >(); constructor( private readonly httpService: HttpService, private readonly fanavaranAuditService: FanavaranAuditService, @InjectModel(FanavaranAuthToken.name) private readonly authTokenModel: Model, ) {} /** 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; 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 { 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 { 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 { 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 { 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) => { if (headers) { delete headers["Content-Type"]; delete headers["content-type"]; } return _data; }, ]; } private async fetchAppToken( config: Pick, auditSession?: FanavaranAuditSession, ): Promise { 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, auditSession?: FanavaranAuditSession, ): Promise { 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, ), ); } }