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; } 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('fayda'); if (!fayda) { throw new Error('Fayda config namespace not registered'); } this.faydaConfig = fayda; this.stubEnabled = this.config.get('VERIFAYDA_ENABLED', false); this.stubApiUrl = this.config.get( 'VERIFAYDA_API_URL', 'https://api.verifayda.gov.et/v2', ); this.stubApiKey = this.config.get('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 { 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 { 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 { const rows = await this.dataSource.query<{ metadata: Record | 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 { 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 { 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 { 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 { 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 { 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; } }