Files
edr-platform/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts
2026-05-25 17:22:49 +03:00

520 lines
15 KiB
TypeScript

import {
BadRequestException,
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
import { PrismaService } from '../../common/prisma.service';
import { FaydaConfig } 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;
userId?: string;
bookingId?: string;
saveToAccount?: boolean;
}
@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,
) {
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.httpClient = axios.create({
baseURL: this.stubApiUrl,
timeout: 10000,
headers: { 'Content-Type': 'application/json', 'X-API-Key': this.stubApiKey },
});
}
// ==========================================================================
// 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,
saveToAccount: input.saveToAccount ?? false,
userId: input.userId ?? null,
bookingId: input.bookingId ?? null,
expiresAt,
},
});
this.logger.log(
`Fayda verification started: purpose=${input.purpose} userId=${input.userId ?? 'none'} bookingId=${input.bookingId ?? 'none'}`,
);
return this.buildAuthorizationUrl({ state, codeChallenge });
}
async handleCallback(query: VerifaydaCallbackDto): Promise<string> {
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,
);
}
return this.buildFailureUrl(query.error);
}
if (!query.code || !query.state) {
this.logger.warn('Fayda callback missing code or state');
return this.buildFailureUrl('missing_parameters');
}
const session = await this.prisma.faydaVerificationSession.findUnique({
where: { state: query.state },
});
if (!session) {
this.logger.warn('Fayda callback with unknown state');
return this.buildFailureUrl('invalid_state');
}
if (session.status !== 'PENDING') {
this.logger.warn(
`Fayda callback for non-pending session (status=${session.status})`,
);
return this.buildFailureUrl('invalid_state');
}
if (session.expiresAt.getTime() < Date.now()) {
await this.markSessionFailed(query.state, 'session_expired');
this.logger.warn('Fayda callback for expired session');
return this.buildFailureUrl('session_expired');
}
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');
}
if (session.purpose === 'PURCHASE') {
await this.handlePurchaseSuccess(session, normalized);
} else {
await this.handleLoginSuccess(session, normalized);
}
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}`,
);
return this.buildSuccessUrl();
} 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,
);
return this.buildFailureUrl(reason);
}
}
async getVerificationStatus(userId: string): Promise<VerificationStatusDto> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { faydaVerified: true, faydaVerifiedAt: true, fullName: true },
});
return {
verified: user?.faydaVerified ?? false,
verifiedAt: user?.faydaVerifiedAt ?? undefined,
fullName: user?.fullName ?? undefined,
};
}
// ==========================================================================
// 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;
userId: 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,
},
});
}
if (session.userId && session.saveToAccount) {
const conflict = await this.prisma.user.findFirst({
where: {
faydaSub: normalized.sub,
NOT: { id: session.userId },
},
select: { id: true },
});
if (conflict) {
throw new FaydaIdentityConflictException();
}
await this.prisma.user.update({
where: { id: session.userId },
data: {
faydaVerified: true,
faydaVerifiedAt: new Date(),
faydaSub: normalized.sub,
},
});
}
}
private async handleLoginSuccess(
_session: { id: string },
_normalized: NormalizedFaydaUserInfo,
): Promise<void> {
// LOGIN flow (User creation / login token issuance)
// The schema currently requires email/phone/passwordHash on User as NOT NULL,
// and the auth controllers haven't been wired to consume Fayda identities yet.
throw new BadRequestException({
code: 'FAYDA_LOGIN_NOT_IMPLEMENTED',
message: 'Login-with-Fayda is not yet implemented',
});
}
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 buildSuccessUrl(): string {
return this.faydaConfig.successRedirectUrl;
}
private buildFailureUrl(reason: string): string {
const url = new URL(this.faydaConfig.failureRedirectUrl);
url.searchParams.set('reason', reason);
return url.toString();
}
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> {
if (!this.stubEnabled) {
this.logger.warn('Verifayda stub is disabled - skipping verification');
return {
verified: false,
failureReason: 'Verifayda integration is disabled',
};
}
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;
}
}