Files
esg/src/providers/implementations/amitis.provider.ts

279 lines
8.7 KiB
TypeScript

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 { 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<AmitisAuthServiceConfig>('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<string> {
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<string> {
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<CentInsurTokenResponse>(
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<string> {
try {
const response = await this.httpClient.post<CentInsurTokenResponse>(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<void> {
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;
if (body.IsSucceed === false) {
throw new Error(
`AMITIS login rejected (LoginStatus=${body.LoginStatus ?? 'unknown'})`,
);
}
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 toAuthError(message: string, error: unknown): 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 new Error(
body ? `${message}: ${status} ${error.message} | body=${body}` : `${message}: ${status} ${error.message}`,
);
}
if (error instanceof Error) {
return new Error(`${message}: ${error.message}`);
}
return new Error(`${message}: ${String(error)}`);
}
}
// Made with Bob