feat(fayda): implement verifayda oidc service

This commit is contained in:
Abubeker Yasin
2026-05-25 17:22:49 +03:00
parent 28448c0268
commit 20151286f2
5 changed files with 906 additions and 47 deletions

View File

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

View File

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

View File

@@ -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<T extends AnyFn>(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>): 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<ConfigService> {
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<ConfigService>;
}
describe('VerifaydaService (OIDC)', () => {
let prisma: ReturnType<typeof buildPrismaMock>;
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<any> = {}) {
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<Partial<Response>>) {
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 });
});
});
});

View File

@@ -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<boolean>('VERIFAYDA_ENABLED', false);
this.apiUrl = this.config.get<string>('VERIFAYDA_API_URL', 'https://api.verifayda.gov.et/v2');
this.apiKey = this.config.get<string>('VERIFAYDA_API_KEY', '');
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.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<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.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;
}
}

View File

@@ -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<string, unknown>;
[key: string]: unknown;
}
export interface NormalizedFaydaUserInfo {
sub: string;
fullName?: string;
phoneNumber?: string;
email?: string;
gender?: string;
birthdate?: string;
picture?: string;
}