forked from Shared/esg
Implement normalized error handling with Persian translations across all exception types, replace legacy NestJS exceptions with AppException, and add unit, integration, and smoke tests. - Add error catalog with gateway-owned codes and Persian messages - Introduce AppException wrapping normalized error envelopes - Add translateError helper for automatic messageFa population - Remove claims module and update provider error normalization - Add unit tests for error contracts and helper functions - Add integration tests for admin and inquiry endpoints - Add smoke tests for real provider connectivity - Add test support utilities (auth mocks, assertions, app factory)
327 lines
10 KiB
TypeScript
327 lines
10 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 { 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<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;
|
|
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
|