Files
edr-platform/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts

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;
}
}