mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 23:00:57 +00:00
562 lines
18 KiB
TypeScript
562 lines
18 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Injectable,
|
|
Logger,
|
|
ServiceUnavailableException,
|
|
UnauthorizedException,
|
|
} from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { InjectDataSource } from '@nestjs/typeorm';
|
|
import { DataSource } from 'typeorm';
|
|
import axios, { AxiosInstance } from 'axios';
|
|
import { PrismaService } from '../../common/prisma.service';
|
|
import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config';
|
|
import {
|
|
generateCodeChallenge,
|
|
generateCodeVerifier,
|
|
generateState,
|
|
} from './utils/pkce.util';
|
|
import { generateClientAssertion } from './utils/client-assertion.util';
|
|
import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto';
|
|
import {
|
|
FaydaIdentityConflictException,
|
|
FaydaTokenExchangeException,
|
|
FaydaUserInfoException,
|
|
} from './verifayda.errors';
|
|
import {
|
|
FaydaTokenResponse,
|
|
FaydaUserInfo,
|
|
NormalizedFaydaUserInfo,
|
|
VerifaydaPurpose,
|
|
} from './verifayda.types';
|
|
|
|
export interface VerifaydaPassengerData {
|
|
fullName: string;
|
|
dateOfBirth: Date;
|
|
gender?: string;
|
|
nationality?: string;
|
|
profileData?: Record<string, any>;
|
|
}
|
|
|
|
export interface VerifaydaVerificationResult {
|
|
verified: boolean;
|
|
passengerData?: VerifaydaPassengerData;
|
|
failureReason?: string;
|
|
}
|
|
|
|
export interface StartVerificationInput {
|
|
purpose: VerifaydaPurpose;
|
|
platform?: FaydaPlatform;
|
|
userId?: string;
|
|
bookingId?: string;
|
|
saveToAccount?: boolean;
|
|
}
|
|
|
|
export interface FaydaUserSummary {
|
|
id: string;
|
|
email: string;
|
|
role: string;
|
|
passengerId?: string;
|
|
agentId?: string;
|
|
}
|
|
|
|
/**
|
|
* Result of completing a verification. `verified` is always true on success.
|
|
* LOGIN additionally returns a JWT + user; PURCHASE returns the verified name.
|
|
*/
|
|
export interface CompleteVerificationResult {
|
|
purpose: VerifaydaPurpose;
|
|
verified: boolean;
|
|
token?: string;
|
|
user?: FaydaUserSummary;
|
|
fullName?: string;
|
|
}
|
|
|
|
@Injectable()
|
|
export class VerifaydaService {
|
|
private readonly logger = new Logger(VerifaydaService.name);
|
|
|
|
|
|
private readonly faydaConfig: FaydaConfig;
|
|
|
|
private readonly httpClient: AxiosInstance;
|
|
private readonly stubEnabled: boolean;
|
|
private readonly stubApiUrl: string;
|
|
private readonly stubApiKey: string;
|
|
|
|
constructor(
|
|
private readonly config: ConfigService,
|
|
private readonly prisma: PrismaService,
|
|
@InjectDataSource() private readonly dataSource: DataSource,
|
|
) {
|
|
const fayda = this.config.get<FaydaConfig>('fayda');
|
|
if (!fayda) {
|
|
throw new Error('Fayda config namespace not registered');
|
|
}
|
|
this.faydaConfig = fayda;
|
|
|
|
this.stubEnabled = this.config.get<boolean>('VERIFAYDA_ENABLED', false);
|
|
this.stubApiUrl = this.config.get<string>(
|
|
'VERIFAYDA_API_URL',
|
|
'https://api.verifayda.gov.et/v2',
|
|
);
|
|
this.stubApiKey = this.config.get<string>('VERIFAYDA_API_KEY', '');
|
|
|
|
this.logger.log(`Verifayda configuration: enabled=${this.stubEnabled}, url=${this.stubApiUrl}`);
|
|
|
|
// Only create HTTP client if Verifayda is enabled
|
|
if (this.stubEnabled) {
|
|
this.httpClient = axios.create({
|
|
baseURL: this.stubApiUrl,
|
|
timeout: 10000,
|
|
headers: { 'Content-Type': 'application/json', 'X-API-Key': this.stubApiKey },
|
|
});
|
|
this.logger.log('Verifayda HTTP client created');
|
|
} else {
|
|
this.logger.log('Verifayda HTTP client NOT created (disabled)');
|
|
}
|
|
}
|
|
|
|
// ==========================================================================
|
|
// OIDC flow
|
|
// ==========================================================================
|
|
|
|
async startVerification(input: StartVerificationInput): Promise<string> {
|
|
if (!this.faydaConfig.enabled) {
|
|
throw new ServiceUnavailableException({
|
|
code: 'FAYDA_DISABLED',
|
|
message: 'Fayda integration is not enabled',
|
|
});
|
|
}
|
|
|
|
const state = generateState();
|
|
const codeVerifier = generateCodeVerifier();
|
|
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
const expiresAt = new Date(
|
|
Date.now() + this.faydaConfig.sessionTtlMinutes * 60_000,
|
|
);
|
|
|
|
await this.prisma.faydaVerificationSession.create({
|
|
data: {
|
|
state,
|
|
codeVerifier,
|
|
purpose: input.purpose,
|
|
platform: input.platform ?? 'WEB',
|
|
saveToAccount: input.saveToAccount ?? false,
|
|
iamUserId: input.userId ?? null,
|
|
bookingId: input.bookingId ?? null,
|
|
expiresAt,
|
|
},
|
|
});
|
|
|
|
this.logger.log(
|
|
`Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'} bookingId=${input.bookingId ?? 'none'}`,
|
|
);
|
|
|
|
return this.buildAuthorizationUrl({ state, codeChallenge });
|
|
}
|
|
|
|
|
|
async completeVerification(
|
|
query: VerifaydaCallbackDto,
|
|
): Promise<CompleteVerificationResult> {
|
|
if (query.error) {
|
|
this.logger.warn(`Fayda callback returned error: ${query.error}`);
|
|
if (query.state) {
|
|
await this.markSessionFailed(
|
|
query.state,
|
|
query.error,
|
|
query.error_description,
|
|
);
|
|
}
|
|
throw new BadRequestException({
|
|
code: 'FAYDA_AUTH_ERROR',
|
|
message: query.error,
|
|
description: query.error_description,
|
|
});
|
|
}
|
|
|
|
if (!query.code || !query.state) {
|
|
throw new BadRequestException({
|
|
code: 'FAYDA_MISSING_PARAMETERS',
|
|
message: 'code and state are required',
|
|
});
|
|
}
|
|
|
|
const session = await this.prisma.faydaVerificationSession.findUnique({
|
|
where: { state: query.state },
|
|
});
|
|
if (!session || session.status !== 'PENDING') {
|
|
this.logger.warn('Fayda complete with unknown or non-pending state');
|
|
throw new BadRequestException({
|
|
code: 'FAYDA_INVALID_STATE',
|
|
message: 'Verification session is invalid or already used',
|
|
});
|
|
}
|
|
if (session.expiresAt.getTime() < Date.now()) {
|
|
await this.markSessionFailed(query.state, 'session_expired');
|
|
throw new BadRequestException({
|
|
code: 'FAYDA_SESSION_EXPIRED',
|
|
message: 'Verification session has expired; start again',
|
|
});
|
|
}
|
|
|
|
try {
|
|
const tokens = await this.exchangeCodeForTokens(
|
|
query.code,
|
|
session.codeVerifier,
|
|
);
|
|
const userInfo = await this.fetchUserInfo(tokens.access_token);
|
|
const normalized = this.normalizeUserInfo(userInfo);
|
|
|
|
if (!normalized.sub) {
|
|
throw new FaydaUserInfoException('Fayda userinfo missing required sub');
|
|
}
|
|
|
|
let result: CompleteVerificationResult;
|
|
if (session.purpose === 'PURCHASE') {
|
|
await this.handlePurchaseSuccess(session, normalized);
|
|
result = {
|
|
purpose: 'PURCHASE',
|
|
verified: true,
|
|
fullName: normalized.fullName,
|
|
};
|
|
} else {
|
|
const { userId } = await this.handleLoginSuccess(normalized);
|
|
const login = await this.issueLoginToken(userId);
|
|
result = { purpose: 'LOGIN', verified: true, ...login };
|
|
}
|
|
|
|
await this.prisma.faydaVerificationSession.update({
|
|
where: { id: session.id },
|
|
data: { status: 'COMPLETED', completedAt: new Date(), codeVerifier: '' },
|
|
});
|
|
|
|
this.logger.log(
|
|
`Fayda verification completed: purpose=${session.purpose} platform=${session.platform}`,
|
|
);
|
|
return result;
|
|
} catch (err) {
|
|
const reason = this.classifyFailureReason(err);
|
|
this.logger.error(
|
|
`Fayda verification failed: reason=${reason} message=${(err as Error).message}`,
|
|
);
|
|
await this.markSessionFailed(
|
|
query.state,
|
|
reason,
|
|
(err as Error).message,
|
|
);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
private async issueLoginToken(
|
|
_userId: string,
|
|
): Promise<{ token: string; user: FaydaUserSummary }> {
|
|
throw new UnauthorizedException({
|
|
code: 'FAYDA_LOGIN_MIGRATED_TO_IAM',
|
|
message: 'Fayda login tokens are issued by the IAM package auth endpoints.',
|
|
});
|
|
}
|
|
|
|
async getVerificationStatus(iamUserId: string): Promise<VerificationStatusDto> {
|
|
const rows = await this.dataSource.query<{ metadata: Record<string, any> | null; name: { en: string; am: string } | null }[]>(
|
|
`SELECT metadata, name FROM iam.users WHERE id = $1 LIMIT 1`,
|
|
[iamUserId],
|
|
);
|
|
const iam = rows[0] ?? null;
|
|
const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true';
|
|
const faydaVerifiedAt = iam?.metadata?.faydaVerifiedAt ? new Date(iam.metadata.faydaVerifiedAt) : undefined;
|
|
const fullName = iam?.name?.en ?? iam?.name?.am ?? undefined;
|
|
return { verified: faydaVerified, verifiedAt: faydaVerifiedAt, fullName };
|
|
}
|
|
|
|
// ==========================================================================
|
|
// OIDC internals
|
|
// ==========================================================================
|
|
|
|
private buildAuthorizationUrl(args: {
|
|
state: string;
|
|
codeChallenge: string;
|
|
}): string {
|
|
const params = new URLSearchParams({
|
|
client_id: this.faydaConfig.clientId,
|
|
response_type: 'code',
|
|
redirect_uri: this.faydaConfig.redirectUri,
|
|
scope: this.faydaConfig.scope,
|
|
state: args.state,
|
|
code_challenge: args.codeChallenge,
|
|
code_challenge_method: 'S256',
|
|
acr_values: this.faydaConfig.acrValues,
|
|
claims_locales: this.faydaConfig.claimsLocales,
|
|
});
|
|
|
|
const claims = {
|
|
userinfo: {
|
|
name: { essential: true },
|
|
phone_number: { essential: true },
|
|
email: { essential: false },
|
|
birthdate: { essential: true },
|
|
gender: { essential: false },
|
|
picture: { essential: false },
|
|
},
|
|
id_token: {},
|
|
};
|
|
params.set('claims', JSON.stringify(claims));
|
|
|
|
return `${this.faydaConfig.authorizationEndpoint}?${params.toString()}`;
|
|
}
|
|
|
|
private async exchangeCodeForTokens(
|
|
code: string,
|
|
codeVerifier: string,
|
|
): Promise<FaydaTokenResponse> {
|
|
const clientAssertion = await generateClientAssertion({
|
|
clientId: this.faydaConfig.clientId,
|
|
audience: this.faydaConfig.tokenEndpoint,
|
|
privateJwk: this.faydaConfig.privateJwk,
|
|
});
|
|
|
|
const body = new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
redirect_uri: this.faydaConfig.redirectUri,
|
|
client_id: this.faydaConfig.clientId,
|
|
client_assertion_type:
|
|
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
|
client_assertion: clientAssertion,
|
|
code_verifier: codeVerifier,
|
|
});
|
|
|
|
const response = await fetch(this.faydaConfig.tokenEndpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
let detail = '';
|
|
try {
|
|
detail = await response.text();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
throw new FaydaTokenExchangeException(
|
|
`Fayda token endpoint returned ${response.status}${detail ? `: ${detail}` : ''}`,
|
|
);
|
|
}
|
|
|
|
return (await response.json()) as FaydaTokenResponse;
|
|
}
|
|
|
|
private async fetchUserInfo(accessToken: string): Promise<FaydaUserInfo> {
|
|
const response = await fetch(this.faydaConfig.userInfoEndpoint, {
|
|
method: 'GET',
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new FaydaUserInfoException(
|
|
`Fayda userinfo endpoint returned ${response.status}`,
|
|
);
|
|
}
|
|
|
|
const contentType = response.headers.get('content-type') ?? '';
|
|
const raw = await response.text();
|
|
|
|
if (contentType.includes('application/json')) {
|
|
return JSON.parse(raw) as FaydaUserInfo;
|
|
}
|
|
|
|
// Signed JWT response — decode payload (signature verification = production TODO)
|
|
if (raw.split('.').length === 3) {
|
|
const payloadB64 = raw.split('.')[1];
|
|
const normalizedB64 = payloadB64.replace(/-/g, '+').replace(/_/g, '/');
|
|
const json = Buffer.from(normalizedB64, 'base64').toString('utf8');
|
|
return JSON.parse(json) as FaydaUserInfo;
|
|
}
|
|
|
|
throw new FaydaUserInfoException(
|
|
'Unsupported Fayda userinfo response format',
|
|
);
|
|
}
|
|
|
|
private normalizeUserInfo(raw: FaydaUserInfo): NormalizedFaydaUserInfo {
|
|
return {
|
|
sub: raw.sub,
|
|
fullName: raw.name ?? raw['name#en'] ?? raw['name#am'],
|
|
phoneNumber:
|
|
raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone,
|
|
email: raw.email,
|
|
gender: raw.gender,
|
|
birthdate: raw.birthdate,
|
|
picture: raw.picture,
|
|
};
|
|
}
|
|
|
|
private async handlePurchaseSuccess(
|
|
session: {
|
|
id: string;
|
|
iamUserId: string | null;
|
|
bookingId: string | null;
|
|
saveToAccount: boolean;
|
|
},
|
|
normalized: NormalizedFaydaUserInfo,
|
|
): Promise<void> {
|
|
if (session.bookingId) {
|
|
await this.prisma.bookingSeat.updateMany({
|
|
where: { bookingId: session.bookingId },
|
|
data: {
|
|
faydaVerifiedAt: new Date(),
|
|
faydaSub: normalized.sub,
|
|
faydaVerifiedName: normalized.fullName ?? null,
|
|
},
|
|
});
|
|
}
|
|
|
|
const iamUserId = session.iamUserId;
|
|
if (iamUserId && session.saveToAccount) {
|
|
const conflicts = await this.dataSource.query<{ id: string }[]>(
|
|
`SELECT id FROM iam.users WHERE metadata->>'faydaSub' = $1 AND id != $2 LIMIT 1`,
|
|
[normalized.sub, iamUserId],
|
|
);
|
|
if (conflicts.length) throw new FaydaIdentityConflictException();
|
|
|
|
await this.dataSource.query(
|
|
`UPDATE iam.users SET metadata = COALESCE(metadata, '{}') || $1::jsonb WHERE id = $2`,
|
|
[JSON.stringify({ faydaSub: normalized.sub, faydaVerified: true, faydaVerifiedAt: new Date().toISOString() }), iamUserId],
|
|
);
|
|
}
|
|
}
|
|
|
|
// LOGIN via Fayda is now handled entirely by the IAM package's own OIDC flow.
|
|
// This method is kept as a stub so completeVerification() still compiles;
|
|
// it throws immediately without touching the database.
|
|
private async handleLoginSuccess(
|
|
_normalized: NormalizedFaydaUserInfo,
|
|
): Promise<{ userId: string }> {
|
|
throw new UnauthorizedException({
|
|
code: 'FAYDA_LOGIN_MIGRATED_TO_IAM',
|
|
message: 'Fayda login tokens are issued by the IAM package at /v1/auth/fayda endpoints.',
|
|
});
|
|
}
|
|
|
|
private async markSessionFailed(
|
|
state: string,
|
|
errorCode: string,
|
|
errorDescription?: string,
|
|
): Promise<void> {
|
|
await this.prisma.faydaVerificationSession.updateMany({
|
|
where: { state, status: 'PENDING' },
|
|
data: {
|
|
status: 'FAILED',
|
|
errorCode,
|
|
errorDescription: errorDescription ?? null,
|
|
completedAt: new Date(),
|
|
codeVerifier: '',
|
|
},
|
|
});
|
|
}
|
|
|
|
private classifyFailureReason(err: unknown): string {
|
|
if (err instanceof FaydaIdentityConflictException) return 'identity_conflict';
|
|
if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed';
|
|
if (err instanceof FaydaUserInfoException) return 'userinfo_failed';
|
|
return 'verification_failed';
|
|
}
|
|
|
|
// ==========================================================================
|
|
// DEPRECATED: legacy stub flow
|
|
// ==========================================================================
|
|
|
|
/** @deprecated Use the OIDC flow instead. Retained until cleanup. */
|
|
async verifyNationalId(
|
|
nationalId: string,
|
|
bookingId?: string,
|
|
): Promise<VerifaydaVerificationResult> {
|
|
this.logger.log(`verifyNationalId called: stubEnabled=${this.stubEnabled}, type=${typeof this.stubEnabled}`);
|
|
|
|
if (!this.stubEnabled) {
|
|
this.logger.warn('Verifayda not configured — returning mock data (development mode)');
|
|
return {
|
|
verified: true,
|
|
passengerData: {
|
|
fullName: 'Mock Passenger',
|
|
dateOfBirth: new Date('1990-01-01'),
|
|
gender: 'Male',
|
|
nationality: 'Ethiopian',
|
|
},
|
|
};
|
|
}
|
|
|
|
const requestPayload = {
|
|
nationalId,
|
|
requestedFields: ['fullName', 'dateOfBirth', 'gender', 'nationality'],
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
|
|
try {
|
|
this.logger.log('Verifying national ID via legacy Verifayda stub');
|
|
const response = await this.httpClient.post('/verify', requestPayload);
|
|
const { data } = response;
|
|
|
|
if (data.status === 'verified' && data.citizen) {
|
|
const passengerData: VerifaydaPassengerData = {
|
|
fullName: data.citizen.fullName,
|
|
dateOfBirth: new Date(data.citizen.dateOfBirth),
|
|
gender: data.citizen.gender,
|
|
nationality: data.citizen.nationality || 'Ethiopian',
|
|
profileData: data.citizen,
|
|
};
|
|
|
|
await this.prisma.verifaydaVerification.create({
|
|
data: {
|
|
bookingId,
|
|
nationalId,
|
|
requestPayload,
|
|
responsePayload: data,
|
|
verified: true,
|
|
verifiedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
return { verified: true, passengerData };
|
|
}
|
|
|
|
const failureReason = data.message || 'Verification failed';
|
|
await this.prisma.verifaydaVerification.create({
|
|
data: {
|
|
bookingId,
|
|
nationalId,
|
|
requestPayload,
|
|
responsePayload: data,
|
|
verified: false,
|
|
failureReason,
|
|
},
|
|
});
|
|
return { verified: false, failureReason };
|
|
} catch (error: any) {
|
|
const errorMessage =
|
|
error.response?.data?.message || error.message || 'Unknown error';
|
|
await this.prisma.verifaydaVerification.create({
|
|
data: {
|
|
bookingId,
|
|
nationalId,
|
|
requestPayload,
|
|
verified: false,
|
|
failureReason: errorMessage,
|
|
},
|
|
});
|
|
this.logger.error(`Verifayda stub error: ${errorMessage}`);
|
|
throw new BadRequestException(
|
|
`National ID verification failed: ${errorMessage}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** @deprecated Use `faydaConfig.enabled` for the OIDC flow. */
|
|
isEnabled(): boolean {
|
|
return this.stubEnabled;
|
|
}
|
|
}
|