import { BadRequestException, Injectable, Logger, ServiceUnavailableException, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { generateToken, generateRefreshToken } from '@tria-plc/api-common/utils/token'; import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config'; import { FaydaVerificationSession } from './entities/fayda-verification-session.entity'; import { generateCodeChallenge, generateCodeVerifier, generateState, } from './utils/pkce.util'; import { generateClientAssertion } from './utils/client-assertion.util'; import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto'; import { FaydaTokenExchangeException, FaydaUserInfoException, } from './verifayda.errors'; import { FaydaTokenResponse, FaydaUserInfo, NormalizedFaydaUserInfo, VerifaydaPurpose, } from './verifayda.types'; export interface StartVerificationInput { purpose: VerifaydaPurpose; platform?: FaydaPlatform; userId?: string; // iamUserId of the authenticated user, if any wantsPasswordSetup?: boolean; } export interface FaydaUserSummary { id: string; email: string; role: string; } /** * Result of completing a verification. `verified` is always true on success. * LOGIN additionally returns a JWT + user; VERIFY returns the verified identity * attributes (name, email, phone, dob, gender) for the caller to consume. */ export interface CompleteVerificationResult { purpose: VerifaydaPurpose; verified: boolean; token?: string; refreshToken?: string; requiresPassword?: boolean; promptPasswordSetup?: boolean; iamUserId?: string; user?: FaydaUserSummary; /** Fayda OIDC subject — the stable key a verified identity is stored under. */ sub?: string; fullName?: string; email?: string; phoneNumber?: string; birthdate?: string; gender?: string; /** Verified address, English rendering (falls back to Amharic). */ address?: string; userDataSaved?: boolean; } @Injectable() export class VerifaydaService { private readonly logger = new Logger(VerifaydaService.name); private readonly faydaConfig: FaydaConfig; constructor( private readonly config: ConfigService, @InjectRepository(FaydaVerificationSession) private readonly sessionRepo: Repository, @InjectDataSource() private readonly dataSource: DataSource, ) { const fayda = this.config.get('fayda'); if (!fayda) { throw new Error('Fayda config namespace not registered'); } this.faydaConfig = fayda; } // ========================================================================== // 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.sessionRepo.save( this.sessionRepo.create({ state, codeVerifier, purpose: input.purpose, platform: input.platform ?? 'WEB', saveToAccount: input.wantsPasswordSetup ?? false, iamUserId: input.userId ?? null, expiresAt, }), ); this.logger.log( `Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'}`, ); return this.buildAuthorizationUrl({ state, codeChallenge, redirectUri: this.redirectUriForPlatform(input.platform ?? 'WEB'), }); } /** * Each client lands on its own registered redirect_uri: MOBILE on the base * one, the customer portal on its own origin, everything else (backoffice) on * the web one. All three must be registered with eSignet. */ private redirectUriForPlatform(platform?: FaydaPlatform): string { if (platform === 'MOBILE') return this.faydaConfig.redirectUri; if (platform === 'PORTAL') return this.faydaConfig.portalRedirectUri; return this.faydaConfig.webRedirectUri; } 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.sessionRepo.findOne({ 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, this.redirectUriForPlatform(session.platform as FaydaPlatform), ); 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 === 'LOGIN') { const { userId } = await this.handleLoginSuccess(normalized); const login = await this.issueLoginToken(userId); result = { purpose: 'LOGIN', verified: true, ...login }; } else { // VERIFY — prove identity, save to IAM, return verified attributes + short-lived token. const { iamUserId, userDataSaved } = await this.upsertIamUser(normalized); let sessionToken: { token: string; refreshToken: string; requiresPassword: boolean } | undefined; if (iamUserId) { try { sessionToken = await this.createFaydaSession(iamUserId); } catch (err) { this.logger.warn(`Fayda session creation failed: ${(err as Error).message}`); } } result = { purpose: 'VERIFY', verified: true, sub: normalized.sub, fullName: normalized.fullName, email: normalized.email, phoneNumber: normalized.phoneNumber, birthdate: normalized.birthdate, gender: normalized.gender, address: normalized.addressEn ?? normalized.addressAm, userDataSaved, iamUserId: iamUserId ?? undefined, token: sessionToken?.token, refreshToken: sessionToken?.refreshToken, requiresPassword: sessionToken?.requiresPassword, promptPasswordSetup: session.saveToAccount && (sessionToken?.requiresPassword ?? false), }; } await this.sessionRepo.update(session.id, { 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<{ verified_by: string | null; updated_at: Date | null; name: { en: string; am: string } | null }[]>( `SELECT verified_by, updated_at, name FROM iam.users WHERE id = $1 LIMIT 1`, [iamUserId], ); const iam = rows[0] ?? null; const faydaVerified = iam?.verified_by === 'fayda'; const faydaVerifiedAt = faydaVerified && iam?.updated_at ? new Date(iam.updated_at) : 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; redirectUri: string; }): string { const params = new URLSearchParams({ client_id: this.faydaConfig.clientId, response_type: 'code', redirect_uri: args.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, }); // Every claim is marked essential so eSignet shows them locked/pre-checked // on the consent screen — the user cannot toggle any off; they either // consent to all of them or the whole flow is cancelled (?error=...). const claims = { userinfo: { name: { essential: true }, phone_number: { essential: true }, email: { essential: true }, birthdate: { essential: true }, gender: { essential: true }, address: { essential: true }, nationality: { essential: true }, picture: { essential: true }, }, id_token: {}, }; params.set('claims', JSON.stringify(claims)); return `${this.faydaConfig.authorizationEndpoint}?${params.toString()}`; } private async exchangeCodeForTokens( code: string, codeVerifier: string, redirectUri: 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: 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 { const nameEn = raw['name#en'] as string | undefined; const nameAm = raw['name#am'] as string | undefined; const genderEn = raw['gender#en'] as string | undefined; const genderAm = raw['gender#am'] as string | undefined; const addressEn = raw['address#en'] as string | undefined; const addressAm = raw['address#am'] as string | undefined; const rawPhone = (raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone) as string | undefined; return { sub: raw.sub, fullName: (raw.name as string | undefined) ?? nameEn ?? nameAm, phoneNumber: rawPhone ? this.standardizePhoneNumber(rawPhone) : undefined, rawPhoneNumber: rawPhone, email: raw.email as string | undefined, gender: genderEn ?? genderAm ?? (raw.gender as string | undefined), birthdate: raw.birthdate as string | undefined, picture: raw.picture as string | undefined, nameEn, nameAm, genderEn, genderAm, addressEn, addressAm, }; } private standardizePhoneNumber(phone: string): string { const digits = phone.replace(/\D/g, ''); if (digits.startsWith('251')) return `+${digits}`; if (digits.startsWith('0')) return `+251${digits.slice(1)}`; return `+${digits}`; } // LOGIN via Fayda is 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 upsertIamUser( normalized: NormalizedFaydaUserInfo, ): Promise<{ iamUserId: string | null; userDataSaved: boolean }> { try { const iamMetadata = { sub: normalized.sub, address: { am: normalized.addressAm ?? '', en: normalized.addressEn ?? '' }, email: normalized.email ?? '', gender: { am: normalized.genderAm ?? '', en: normalized.genderEn ?? '' }, name: { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' }, phoneNumber: normalized.rawPhoneNumber ?? '', }; // Step 1 — already linked to this Fayda sub; ensure verified_by is set const bySub = await this.dataSource.query<{ id: string }[]>( `SELECT id FROM iam.users WHERE metadata->>'sub' = $1 LIMIT 1`, [normalized.sub], ); if (bySub.length > 0) { await this.dataSource.query( `UPDATE iam.users SET verified_by = 'fayda', updated_at = NOW() WHERE id = $1`, [bySub[0].id], ); return { iamUserId: bySub[0].id, userDataSaved: true }; } // Step 2 — existing user by phone or email, not yet Fayda-verified const conditions: string[] = []; const params: unknown[] = []; if (normalized.phoneNumber) { params.push(normalized.phoneNumber); conditions.push(`phone_number = $${params.length}`); } if (normalized.email) { params.push(normalized.email); conditions.push(`email = $${params.length}`); } if (conditions.length > 0) { const byContact = await this.dataSource.query<{ id: string }[]>( `SELECT id FROM iam.users WHERE ${conditions.join(' OR ')} LIMIT 1`, params, ); if (byContact.length > 0) { const existingId = byContact[0].id; await this.dataSource.query( `UPDATE iam.users SET metadata = COALESCE(metadata, '{}'::jsonb) || $1::jsonb, verified_by = 'fayda', updated_at = NOW() WHERE id = $2`, [JSON.stringify(iamMetadata), existingId], ); return { iamUserId: existingId, userDataSaved: true }; } } // Step 3 — new user const name = { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' }; const username = normalized.phoneNumber ?? normalized.email ?? normalized.sub; const inserted = await this.dataSource.query<{ id: string }[]>( `INSERT INTO iam.users ( id, name, username, email, phone_number, metadata, user_type, status, is_active, has_set_password, is_phone_number_verified, verified_by, created_at, updated_at ) VALUES ( gen_random_uuid(), $1::jsonb, $2, $3, $4, $5::jsonb, 'individual', 'submitted', true, false, false, 'fayda', NOW(), NOW() ) RETURNING id`, [ JSON.stringify(name), username, normalized.email ?? null, normalized.phoneNumber ?? null, JSON.stringify(iamMetadata), ], ); return { iamUserId: inserted[0].id, userDataSaved: true }; } catch (err) { this.logger.error(`Fayda IAM upsert failed: ${(err as Error).message}`); return { iamUserId: null, userDataSaved: false }; } } private async createFaydaSession( iamUserId: string, ): Promise<{ token: string; refreshToken: string; requiresPassword: boolean }> { const rows = await this.dataSource.query<{ id: string; email: string; name: { en: string; am: string } | null; username: string; phone_number: string | null; has_set_password: boolean; status: string; }[]>( `SELECT id, email, name, username, phone_number, has_set_password, status FROM iam.users WHERE id = $1 LIMIT 1`, [iamUserId], ); if (!rows.length) throw new Error(`IAM user ${iamUserId} not found`); const u = rows[0]; const userInfo = { id: u.id, email: u.email ?? '', name: u.name ?? { en: '', am: '' }, userType: 'individual', status: u.status, hasSetPassword: u.has_set_password, isPhoneNumberVerified: false, hasFinishedRegistration: false, hasFinishedDMSOnboarding: false, username: u.username, phoneNumber: u.phone_number ?? '', roles: [], permissions: [], employee: [], }; const sessions = await this.dataSource.query<{ id: string }[]>( `INSERT INTO iam.sessions (id, email, device, "userInfo", expiry_time, refresh_count, status, user_id) VALUES (gen_random_uuid(), $1, 'fayda-verify', $2::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $3) ON CONFLICT (user_id, device) DO UPDATE SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo", expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW() RETURNING id`, [u.email ?? '', JSON.stringify(userInfo), iamUserId], ); const sessionId = sessions[0].id; const token = generateToken({ id: sessionId }); const refreshToken = generateRefreshToken({ id: sessionId }); return { token, refreshToken, requiresPassword: !u.has_set_password }; } private async markSessionFailed( state: string, errorCode: string, errorDescription?: string, ): Promise { await this.sessionRepo.update( { state, status: 'PENDING' }, { status: 'FAILED', errorCode, errorDescription: errorDescription ?? null, completedAt: new Date(), codeVerifier: '', }, ); } private classifyFailureReason(err: unknown): string { if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed'; if (err instanceof FaydaUserInfoException) return 'userinfo_failed'; return 'verification_failed'; } }