mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
feat(fayda): implement verifayda oidc service
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user