From 20151286f27a1abfb7ebafd1589234dd2a2260d7 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 25 May 2026 17:22:49 +0300 Subject: [PATCH] feat(fayda): implement verifayda oidc service --- .../src/modules/verifayda/verifayda.dto.ts | 42 ++ .../src/modules/verifayda/verifayda.errors.ts | 19 + .../verifayda/verifayda.service.spec.ts | 385 ++++++++++++++ .../modules/verifayda/verifayda.service.ts | 471 ++++++++++++++++-- .../src/modules/verifayda/verifayda.types.ts | 36 ++ 5 files changed, 906 insertions(+), 47 deletions(-) create mode 100644 apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts create mode 100644 apps/edr-passenger-api/src/modules/verifayda/verifayda.errors.ts create mode 100644 apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts create mode 100644 apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts new file mode 100644 index 000000000..388c8b438 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator'; + +export class StartVerificationDto { + @ApiPropertyOptional({ + enum: ['LOGIN', 'PURCHASE'], + default: 'PURCHASE', + description: 'Reason for verification.', + }) + @IsOptional() + @IsIn(['LOGIN', 'PURCHASE']) + purpose?: 'LOGIN' | 'PURCHASE'; + + @ApiPropertyOptional({ + description: + 'Booking the verification should attach to (PURCHASE flow). If omitted, the session is anchored only to the user.', + }) + @IsOptional() + @IsString() + bookingId?: string; + + @ApiPropertyOptional({ + description: + 'When true and the user is logged in, copy faydaVerified=true / faydaSub onto their User record after verification.', + }) + @IsOptional() + @IsBoolean() + saveToAccount?: boolean; +} + +export class VerifaydaCallbackDto { + @ApiPropertyOptional() @IsOptional() @IsString() code?: string; + @ApiPropertyOptional() @IsOptional() @IsString() state?: string; + @ApiPropertyOptional() @IsOptional() @IsString() error?: string; + @ApiPropertyOptional() @IsOptional() @IsString() error_description?: string; +} + +export class VerificationStatusDto { + @ApiProperty() verified: boolean; + @ApiPropertyOptional() verifiedAt?: Date; + @ApiPropertyOptional() fullName?: string; +} diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.errors.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.errors.ts new file mode 100644 index 000000000..a7d531102 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.errors.ts @@ -0,0 +1,19 @@ +import { BadGatewayException, ConflictException } from '@nestjs/common'; + +export class FaydaTokenExchangeException extends BadGatewayException { + constructor(message = 'Fayda token exchange failed') { + super({ code: 'FAYDA_TOKEN_EXCHANGE_FAILED', message }); + } +} + +export class FaydaUserInfoException extends BadGatewayException { + constructor(message = 'Fayda userinfo fetch failed') { + super({ code: 'FAYDA_USERINFO_FAILED', message }); + } +} + +export class FaydaIdentityConflictException extends ConflictException { + constructor(message = 'This Fayda identity is already linked to another account') { + super({ code: 'FAYDA_IDENTITY_CONFLICT', message }); + } +} diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts new file mode 100644 index 000000000..55c670946 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts @@ -0,0 +1,385 @@ +import { ConfigService } from '@nestjs/config'; +import { exportJWK, generateKeyPair, type JWK } from 'jose'; +import { PrismaService } from '../../common/prisma.service'; +import { FaydaConfig } from '../../config/fayda.config'; +import { VerifaydaService } from './verifayda.service'; + +type AnyFn = (...args: any[]) => any; + +function mockFn(impl?: T): jest.Mock { + return impl ? jest.fn(impl) : jest.fn(); +} + +function buildPrismaMock() { + return { + faydaVerificationSession: { + create: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + updateMany: jest.fn(), + }, + bookingSeat: { + updateMany: jest.fn(), + }, + user: { + findUnique: jest.fn(), + findFirst: jest.fn(), + update: jest.fn(), + }, + verifaydaVerification: { create: jest.fn() }, + }; +} + +function buildConfig(overrides?: Partial): FaydaConfig { + return { + enabled: true, + clientId: 'edr-test-client', + authorizationEndpoint: 'https://esignet.test/authorize', + tokenEndpoint: 'https://esignet.test/token', + userInfoEndpoint: 'https://esignet.test/userinfo', + redirectUri: 'https://api.edr.test/api/v1/fayda/verification/callback', + privateJwk: { kty: 'RSA', n: '', e: '', d: '' }, + successRedirectUrl: 'https://passenger.edr.test/verify/success', + failureRedirectUrl: 'https://passenger.edr.test/verify/failure', + scope: 'openid profile email', + acrValues: 'mosip:idp:acr:generated-code', + claimsLocales: 'en am', + sessionTtlMinutes: 10, + ...overrides, + }; +} + +function buildConfigService(faydaConfig: FaydaConfig): jest.Mocked { + return { + get: jest.fn((key: string, defaultValue?: unknown) => { + if (key === 'fayda') return faydaConfig; + if (key === 'VERIFAYDA_ENABLED') return false; + return defaultValue; + }), + } as unknown as jest.Mocked; +} + +describe('VerifaydaService (OIDC)', () => { + let prisma: ReturnType; + let service: VerifaydaService; + let realPrivateJwk: JWK; + + beforeAll(async () => { + const kp = await generateKeyPair('RS256', { extractable: true }); + realPrivateJwk = await exportJWK(kp.privateKey); + realPrivateJwk.kty = 'RSA'; + }); + + beforeEach(() => { + prisma = buildPrismaMock(); + const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] }); + service = new VerifaydaService( + buildConfigService(cfg), + prisma as unknown as PrismaService, + ); + (global as any).fetch = jest.fn(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('startVerification', () => { + it('persists a session and returns a fully-formed authorize URL', async () => { + prisma.faydaVerificationSession.create.mockResolvedValue({}); + + const url = await service.startVerification({ + purpose: 'PURCHASE', + userId: 'user-1', + saveToAccount: true, + }); + + expect(prisma.faydaVerificationSession.create).toHaveBeenCalledTimes(1); + const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data; + expect(created.purpose).toBe('PURCHASE'); + expect(created.saveToAccount).toBe(true); + expect(created.userId).toBe('user-1'); + expect(typeof created.state).toBe('string'); + expect(typeof created.codeVerifier).toBe('string'); + + const parsed = new URL(url); + expect(parsed.origin + parsed.pathname).toBe( + 'https://esignet.test/authorize', + ); + expect(parsed.searchParams.get('client_id')).toBe('edr-test-client'); + expect(parsed.searchParams.get('response_type')).toBe('code'); + expect(parsed.searchParams.get('code_challenge_method')).toBe('S256'); + expect(parsed.searchParams.get('state')).toBe(created.state); + }); + + it('throws ServiceUnavailable when fayda integration is disabled', async () => { + const disabledCfg = buildConfig({ enabled: false }); + const disabledService = new VerifaydaService( + buildConfigService(disabledCfg), + prisma as unknown as PrismaService, + ); + await expect( + disabledService.startVerification({ purpose: 'PURCHASE' }), + ).rejects.toMatchObject({ status: 503 }); + }); + }); + + describe('handleCallback', () => { + function pendingSession(overrides: Partial = {}) { + return { + id: 'session-1', + state: 'state-abc', + codeVerifier: 'verifier-xyz', + purpose: 'PURCHASE', + saveToAccount: false, + status: 'PENDING', + errorCode: null, + errorDescription: null, + userId: null, + bookingId: null, + expiresAt: new Date(Date.now() + 60_000), + ...overrides, + }; + } + + function mockFetchSequence(...responses: Array>) { + const queue = responses.map((r) => ({ + ok: true, + status: 200, + text: async () => '', + json: async () => ({}), + headers: new Headers({ 'content-type': 'application/json' }), + ...r, + })); + (global as any).fetch = jest.fn(() => Promise.resolve(queue.shift())); + } + + it('redirects to failure URL when callback carries an error', async () => { + prisma.faydaVerificationSession.findUnique.mockResolvedValue(null); + + const url = await service.handleCallback({ + error: 'access_denied', + error_description: 'user cancelled', + state: 'state-abc', + }); + + expect(url).toContain('https://passenger.edr.test/verify/failure'); + expect(url).toContain('reason=access_denied'); + expect(prisma.faydaVerificationSession.updateMany).toHaveBeenCalled(); + }); + + it('redirects to failure URL when state is unknown', async () => { + prisma.faydaVerificationSession.findUnique.mockResolvedValue(null); + const url = await service.handleCallback({ + code: 'authcode', + state: 'bogus', + }); + expect(url).toContain('reason=invalid_state'); + }); + + it('redirects to failure URL when session is not pending', async () => { + prisma.faydaVerificationSession.findUnique.mockResolvedValue( + pendingSession({ status: 'COMPLETED' }), + ); + const url = await service.handleCallback({ + code: 'authcode', + state: 'state-abc', + }); + expect(url).toContain('reason=invalid_state'); + }); + + it('redirects to failure URL when session is expired', async () => { + prisma.faydaVerificationSession.findUnique.mockResolvedValue( + pendingSession({ expiresAt: new Date(Date.now() - 1000) }), + ); + const url = await service.handleCallback({ + code: 'authcode', + state: 'state-abc', + }); + expect(url).toContain('reason=session_expired'); + }); + + it('happy path: exchanges code, fetches userinfo, updates booking seat', async () => { + prisma.faydaVerificationSession.findUnique.mockResolvedValue( + pendingSession({ bookingId: 'booking-1' }), + ); + prisma.faydaVerificationSession.update.mockResolvedValue({}); + prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 }); + + mockFetchSequence( + { + json: async () => ({ access_token: 'tok', token_type: 'Bearer' }), + }, + { + headers: new Headers({ 'content-type': 'application/json' }), + text: async () => + JSON.stringify({ + sub: 'fayda-sub-1', + name: 'Test User', + phone_number: '+251911000000', + email: 'test@example.com', + }), + }, + ); + + const url = await service.handleCallback({ + code: 'authcode', + state: 'state-abc', + }); + + expect(url).toBe('https://passenger.edr.test/verify/success'); + expect(prisma.bookingSeat.updateMany).toHaveBeenCalledWith({ + where: { bookingId: 'booking-1' }, + data: expect.objectContaining({ + faydaSub: 'fayda-sub-1', + faydaVerifiedName: 'Test User', + }), + }); + expect(prisma.faydaVerificationSession.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: 'COMPLETED', + codeVerifier: '', + }), + }), + ); + }); + + it('saves to User when saveToAccount=true and no conflict', async () => { + prisma.faydaVerificationSession.findUnique.mockResolvedValue( + pendingSession({ userId: 'user-1', saveToAccount: true }), + ); + prisma.user.findFirst.mockResolvedValue(null); + prisma.user.update.mockResolvedValue({}); + prisma.faydaVerificationSession.update.mockResolvedValue({}); + + mockFetchSequence( + { json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) }, + { + headers: new Headers({ 'content-type': 'application/json' }), + text: async () => + JSON.stringify({ sub: 'fayda-sub-2', name: 'Test User' }), + }, + ); + + const url = await service.handleCallback({ + code: 'authcode', + state: 'state-abc', + }); + + expect(url).toBe('https://passenger.edr.test/verify/success'); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + data: expect.objectContaining({ + faydaVerified: true, + faydaSub: 'fayda-sub-2', + }), + }); + }); + + it('rejects with identity_conflict when faydaSub is on another user', async () => { + prisma.faydaVerificationSession.findUnique.mockResolvedValue( + pendingSession({ userId: 'user-1', saveToAccount: true }), + ); + prisma.user.findFirst.mockResolvedValue({ id: 'other-user' }); + + mockFetchSequence( + { json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) }, + { + headers: new Headers({ 'content-type': 'application/json' }), + text: async () => + JSON.stringify({ sub: 'fayda-sub-3', name: 'Test User' }), + }, + ); + + const url = await service.handleCallback({ + code: 'authcode', + state: 'state-abc', + }); + expect(url).toContain('reason=identity_conflict'); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it('reports token_exchange_failed when token endpoint returns 4xx', async () => { + prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession()); + + mockFetchSequence({ + ok: false, + status: 400, + text: async () => '{"error":"invalid_assertion"}', + }); + + const url = await service.handleCallback({ + code: 'authcode', + state: 'state-abc', + }); + expect(url).toContain('reason=token_exchange_failed'); + }); + + it('reports userinfo_failed when userinfo response is unsupported', async () => { + prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession()); + + mockFetchSequence( + { json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) }, + { + headers: new Headers({ 'content-type': 'text/plain' }), + text: async () => 'not-a-jwt-not-a-json', + }, + ); + + const url = await service.handleCallback({ + code: 'authcode', + state: 'state-abc', + }); + expect(url).toContain('reason=userinfo_failed'); + }); + + it('falls back to localized name (name#en) when name is missing', async () => { + prisma.faydaVerificationSession.findUnique.mockResolvedValue( + pendingSession({ bookingId: 'booking-2' }), + ); + prisma.faydaVerificationSession.update.mockResolvedValue({}); + prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 }); + + mockFetchSequence( + { json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) }, + { + headers: new Headers({ 'content-type': 'application/json' }), + text: async () => + JSON.stringify({ + sub: 'fayda-sub-4', + 'name#en': 'English Name', + 'name#am': 'Amharic Name', + }), + }, + ); + + await service.handleCallback({ code: 'c', state: 'state-abc' }); + + const seatCall = prisma.bookingSeat.updateMany.mock.calls[0][0]; + expect(seatCall.data.faydaVerifiedName).toBe('English Name'); + }); + }); + + describe('getVerificationStatus', () => { + it('returns verified=true when User row has the flag', async () => { + prisma.user.findUnique.mockResolvedValue({ + faydaVerified: true, + faydaVerifiedAt: new Date('2026-01-01T00:00:00Z'), + fullName: 'Test User', + }); + const result = await service.getVerificationStatus('user-1'); + expect(result).toEqual({ + verified: true, + verifiedAt: new Date('2026-01-01T00:00:00Z'), + fullName: 'Test User', + }); + }); + + it('returns verified=false when User row is missing or unverified', async () => { + prisma.user.findUnique.mockResolvedValue(null); + const result = await service.getVerificationStatus('user-x'); + expect(result).toEqual({ verified: false }); + }); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts index d86ad98a2..55227fa6d 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts @@ -1,7 +1,31 @@ -import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + Logger, + ServiceUnavailableException, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { PrismaService } from '../../common/prisma.service'; 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; @@ -17,38 +41,406 @@ export interface VerifaydaVerificationResult { 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 enabled: boolean; - private readonly apiUrl: string; - private readonly apiKey: string; + private readonly stubEnabled: boolean; + private readonly stubApiUrl: string; + private readonly stubApiKey: string; constructor( private readonly config: ConfigService, private readonly prisma: PrismaService, ) { - this.enabled = this.config.get('VERIFAYDA_ENABLED', false); - this.apiUrl = this.config.get('VERIFAYDA_API_URL', 'https://api.verifayda.gov.et/v2'); - this.apiKey = this.config.get('VERIFAYDA_API_KEY', ''); + 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.httpClient = axios.create({ - baseURL: this.apiUrl, + baseURL: this.stubApiUrl, timeout: 10000, - headers: { - 'Content-Type': 'application/json', - 'X-API-Key': this.apiKey, + headers: { 'Content-Type': 'application/json', 'X-API-Key': this.stubApiKey }, + }); + } + + // ========================================================================== + // 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, + 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 { + 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 { + 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 { + 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; + userId: 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, + }, + }); + } + + 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 { + // 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 { + 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 { - if (!this.enabled) { - this.logger.warn('Verifayda is disabled - skipping verification'); + if (!this.stubEnabled) { + this.logger.warn('Verifayda stub is disabled - skipping verification'); return { verified: false, failureReason: 'Verifayda integration is disabled', @@ -62,10 +454,8 @@ export class VerifaydaService { }; try { - this.logger.log(`Verifying national ID via Verifayda 2.0`); - + 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) { @@ -88,36 +478,24 @@ export class VerifaydaService { }, }); - this.logger.log('Verifayda verification successful'); + return { verified: true, passengerData }; + } - return { - verified: true, - passengerData, - }; - } else { - const failureReason = data.message || 'Verification failed'; - - await this.prisma.verifaydaVerification.create({ - data: { - bookingId, - nationalId, - requestPayload, - responsePayload: data, - verified: false, - failureReason, - }, - }); - - this.logger.warn(`Verifayda verification failed: ${failureReason}`); - - return { + 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'; - + const errorMessage = + error.response?.data?.message || error.message || 'Unknown error'; await this.prisma.verifaydaVerification.create({ data: { bookingId, @@ -127,16 +505,15 @@ export class VerifaydaService { failureReason: errorMessage, }, }); - - this.logger.error(`Verifayda API error: ${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.enabled; + return this.stubEnabled; } } diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts new file mode 100644 index 000000000..7c7335c34 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts @@ -0,0 +1,36 @@ +export type VerifaydaPurpose = 'LOGIN' | 'PURCHASE'; + +export interface FaydaTokenResponse { + access_token: string; + id_token?: string; + token_type: string; + expires_in?: number; + scope?: string; +} + +export interface FaydaUserInfo { + sub: string; + name?: string; + 'name#en'?: string; + 'name#am'?: string; + phone_number?: string; + 'phone_number#en'?: string; + 'phone_number#am'?: string; + phone?: string; + email?: string; + gender?: string; + birthdate?: string; + picture?: string; + address?: Record; + [key: string]: unknown; +} + +export interface NormalizedFaydaUserInfo { + sub: string; + fullName?: string; + phoneNumber?: string; + email?: string; + gender?: string; + birthdate?: string; + picture?: string; +}