import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { AxiosError, AxiosInstance } from 'axios'; import { createOutboundAxiosInstance, isOutboundHttpDebugEnabled, } from '../../common/helpers/http-client.helper'; import { InquiryType } from '../../common/enums/inquiry-type.enum'; import { ProviderName } from '../../common/enums/provider-name.enum'; import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto'; import { AppErrorCode, buildNormalizedError, } from '../../common/constants/error-messages'; import { AmitisAuthServiceConfig } from '../../config/configuration'; import { GeneralTokenDocument } from '../schemas/general-token.schema'; import { GeneralTokenService } from '../services/general-token.service'; interface CentInsurTokenResponse { Token?: string; token?: string; AccessToken?: string; accessToken?: string; access_token?: string; RefreshToken?: string; refreshToken?: string; refresh_token?: string; TokenType?: string; tokenType?: string; token_type?: string; ExpiresIn?: number | string; expiresIn?: number | string; expires_in?: number | string; IsSucceed?: boolean; LoginStatus?: number; data?: CentInsurTokenResponse; } interface AmitisAuthToken { accessToken: string; refreshToken?: string; tokenType: string; expiresIn: number; } /** * AMITIS Authentication Service * Provides token-based authentication for Hamta and Moallem providers * This is NOT a provider itself, but an authentication helper service */ @Injectable() export class AmitisProvider { readonly name = ProviderName.AMITIS; private readonly logger = new Logger(AmitisProvider.name); private readonly httpClient: AxiosInstance; private readonly config: AmitisAuthServiceConfig; constructor( configService: ConfigService, private readonly generalTokenService: GeneralTokenService, ) { this.config = configService.get('amitis')!; this.httpClient = createOutboundAxiosInstance({ baseURL: this.config.baseUrl, timeout: this.config.timeout, }); } isEnabled(): boolean { return this.config.enabled && Boolean(this.config.baseUrl); } /** * Get access token for a specific provider and inquiry type * Handles token caching, refresh, and 24-hour expiration * - Access token expires in 20 minutes * - Refresh token expires in 24 hours from creation */ async getAccessToken( providerName: ProviderName, inquiryType: InquiryType, username: string, password: string, ): Promise { const tokenKey = this.getTokenKey(providerName, inquiryType); const latestToken = await this.generalTokenService.getLatestToken(tokenKey); // Use cached token if valid and within 24-hour window if (latestToken && !this.isExpired(latestToken) && this.isWithin24Hours(latestToken)) { return latestToken.accessToken; } // Try to refresh if we have a refresh token and it's within 24 hours if (latestToken?.refreshToken && this.isWithin24Hours(latestToken)) { try { return await this.refreshAccessToken(latestToken, providerName, inquiryType); } catch (error) { this.logger.warn( `AMITIS token refresh failed for ${providerName}/${inquiryType}, requesting a new token: ${ error instanceof Error ? error.message : String(error) }`, ); } } // Login to get new token return this.login(providerName, inquiryType, username, password); } private async login( providerName: ProviderName, inquiryType: InquiryType, username: string, password: string, ): Promise { const loginBody = new URLSearchParams({ username: username.toLowerCase(), password, }); try { const loginUrl = `${this.config.baseUrl}${this.config.loginPath}`; this.logger.log( `AMITIS login → POST ${loginUrl} | provider=${providerName} | inquiry=${inquiryType} | username=${username.toLowerCase()}`, ); const response = await this.httpClient.post( this.config.loginPath, loginBody.toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }, ); if (isOutboundHttpDebugEnabled()) { this.logger.log( `AMITIS login ← ${response.status} | provider=${providerName} | inquiry=${inquiryType} | bodyKeys=${Object.keys(response.data ?? {}).join(',') || 'none'}`, ); } const token = this.extractToken(response.data); await this.saveToken(token, providerName, inquiryType, username, password, 'login'); return token.accessToken; } catch (error) { throw this.toAuthError(`AMITIS login failed for ${providerName}/${inquiryType}`, error); } } private async refreshAccessToken( latestToken: GeneralTokenDocument, providerName: ProviderName, inquiryType: InquiryType, ): Promise { try { const response = await this.httpClient.post(this.config.refreshPath, { Token: latestToken.accessToken, RefreshToken: latestToken.refreshToken, }); const token = this.extractToken(response.data, latestToken.refreshToken); await this.saveToken( token, providerName, inquiryType, latestToken.username, latestToken.clientSecret, 'refresh', ); return token.accessToken; } catch (error) { throw this.toAuthError( `AMITIS refresh failed for ${providerName}/${inquiryType}`, error, ); } } private async saveToken( token: AmitisAuthToken, providerName: ProviderName, inquiryType: InquiryType, username: string, password: string, scope: string, ): Promise { await this.generalTokenService.create({ serviceProvider: this.getTokenKey(providerName, inquiryType), tokenType: token.tokenType, url: this.config.baseUrl, clientId: username.toLowerCase(), clientSecret: password, username: username.toLowerCase(), scope, accessToken: token.accessToken, refreshToken: token.refreshToken, expiresIn: token.expiresIn, expiresAt: new Date(Date.now() + token.expiresIn * 1000), }); } private extractToken( responseBody: CentInsurTokenResponse, fallbackRefreshToken?: string, ): AmitisAuthToken { const body = responseBody.data ?? responseBody; const loginStatus = body.LoginStatus; if (loginStatus === 2 || body.IsSucceed === false) { throw this.createAuthError( loginStatus === 2 ? 'AMITIS username or password is invalid for this service account' : `AMITIS login rejected (LoginStatus=${loginStatus ?? 'unknown'})`, loginStatus === 2 ? 'PROVIDER_AUTH_FAILED' : 'PROVIDER_REJECTED', ); } if (loginStatus !== undefined && loginStatus !== 1 && body.IsSucceed !== true) { throw this.createAuthError( `AMITIS login rejected (LoginStatus=${loginStatus})`, 'PROVIDER_REJECTED', ); } const accessToken = body.Token ?? body.token ?? body.AccessToken ?? body.accessToken ?? body.access_token; if (!accessToken || !String(accessToken).trim()) { const bodyPreview = JSON.stringify(responseBody).slice(0, 500); throw new Error( `AMITIS auth response did not include an access token | body=${bodyPreview}`, ); } const expiresIn = Number(body.ExpiresIn ?? body.expiresIn ?? body.expires_in ?? 20 * 60); return { accessToken, refreshToken: body.RefreshToken ?? body.refreshToken ?? body.refresh_token ?? fallbackRefreshToken, tokenType: body.TokenType ?? body.tokenType ?? body.token_type ?? 'Bearer', expiresIn: Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 20 * 60, }; } private isExpired(token: GeneralTokenDocument): boolean { // Access token expires in 20 minutes, add 60 second buffer const refreshBufferMs = 60 * 1000; return !token.expiresAt || Date.now() + refreshBufferMs >= token.expiresAt.getTime(); } /** * Check if token is within 24-hour window from creation * Refresh token is valid for 24 hours after initial login */ private isWithin24Hours(token: GeneralTokenDocument): boolean { if (!token.createdAt) { return false; } const tokenCreationTime = token.createdAt.getTime(); const currentTime = Date.now(); const twentyFourHoursMs = 24 * 60 * 60 * 1000; return currentTime - tokenCreationTime < twentyFourHoursMs; } private getTokenKey(providerName: ProviderName, inquiryType: InquiryType): string { return `${ProviderName.AMITIS}:${providerName}:${inquiryType}`; } private createAuthError(message: string, code: AppErrorCode): Error { const normalized = buildNormalizedError(code, { message, providerMessage: message, providerCode: code, }); const err = new Error(message); (err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized; return err; } private createProviderAuthError(message: string, error: unknown): Error { const providerCode = error instanceof AxiosError ? String(error.response?.status ?? error.code ?? 'NETWORK_ERROR') : undefined; const providerMessage = error instanceof Error ? error.message : String(error); const normalized = buildNormalizedError('PROVIDER_AUTH_FAILED', { message, providerMessage, providerCode, }); const err = new Error(message); (err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized; return err; } private toAuthError(message: string, error: unknown): Error { if (error instanceof Error && 'normalizedError' in error) { return error; } if (error instanceof AxiosError) { const status = error.response?.status ?? 'NETWORK_ERROR'; const body = error.response?.data !== undefined ? JSON.stringify(error.response.data).slice(0, 500) : undefined; return this.createProviderAuthError( body ? `${message}: ${status} ${error.message} | body=${body}` : `${message}: ${status} ${error.message}`, error, ); } if (error instanceof Error) { return this.createProviderAuthError(`${message}: ${error.message}`, error); } return this.createProviderAuthError(`${message}: ${String(error)}`, error); } } // Made with Bob