diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 6cbefd93f..f99c73afe 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -1271,7 +1271,7 @@ model FaydaVerificationSession { id String @id @default(uuid()) state String @unique codeVerifier String - purpose String @default("PURCHASE") + purpose String @default("VERIFY") // VERIFY | LOGIN platform String @default("WEB") // WEB | MOBILE — recorded for audit saveToAccount Boolean @default(false) status String @default("PENDING") diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts index f1eb25e8e..ac5d0c59c 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts @@ -55,8 +55,9 @@ export class VerifaydaController { summary: 'Start a VeriFayda 2.0 verification session', description: `Creates a verification session and returns the eSignet authorize URL the frontend should send the user to. -- Works for **logged-in users** and **guests**. If a valid bearer token is present, the verification is tied to that user; when \`saveToAccount\` is true their account is marked verified on success. -- For a **PURCHASE** flow, pass \`bookingId\` to stamp the booking's seats as Fayda-verified. +- Works for **logged-in users** and **guests**. If a valid bearer token is present, the verification is tied to that user. +- **VERIFY** (default): the user proves their identity and \`/complete\` returns the verified attributes (name, email, phone, dob, gender). +- **LOGIN**: \`/complete\` resolves/creates the user and returns a JWT. - The returned \`authorizationUrl\` already carries the PKCE \`code_challenge\`, CSRF \`state\`, requested \`claims\`, and \`code_challenge_method=S256\`. The frontend simply navigates to it (full page or popup).`, }) @ApiOkResponse({ @@ -73,11 +74,9 @@ export class VerifaydaController { @Req() req: RequestWithOptionalUser, ): Promise<{ authorizationUrl: string }> { const authorizationUrl = await this.service.startVerification({ - purpose: dto.purpose ?? 'PURCHASE', + purpose: dto.purpose ?? 'VERIFY', platform: dto.platform ?? 'WEB', userId: req.user?.userId, - bookingId: dto.bookingId, - saveToAccount: dto.saveToAccount, }); return { authorizationUrl }; } diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts index 005a3e517..f446b6fb3 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts @@ -1,31 +1,16 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator'; +import { 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({ + enum: ['LOGIN', 'VERIFY'], + default: 'VERIFY', description: - 'Booking the verification should attach to (PURCHASE flow). If omitted, the session is anchored only to the user.', + 'Reason for verification. VERIFY returns the verified identity attributes; LOGIN resolves/creates a user and returns a JWT.', }) @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; + @IsIn(['LOGIN', 'VERIFY']) + purpose?: 'LOGIN' | 'VERIFY'; @ApiPropertyOptional({ enum: ['WEB', 'MOBILE'], @@ -39,8 +24,8 @@ export class StartVerificationDto { } export class CompleteVerificationResultDto { - @ApiProperty({ enum: ['LOGIN', 'PURCHASE'] }) - purpose: 'LOGIN' | 'PURCHASE'; + @ApiProperty({ enum: ['LOGIN', 'VERIFY'] }) + purpose: 'LOGIN' | 'VERIFY'; @ApiProperty() verified: boolean; @@ -58,10 +43,22 @@ export class CompleteVerificationResultDto { agentId?: string; }; - @ApiPropertyOptional({ - description: 'Verified full name from Fayda (PURCHASE flow).', - }) + @ApiPropertyOptional({ description: 'Verified full name from Fayda (VERIFY flow).' }) fullName?: string; + + @ApiPropertyOptional({ description: 'Verified email from Fayda (VERIFY flow).' }) + email?: string; + + @ApiPropertyOptional({ description: 'Verified phone number from Fayda (VERIFY flow).' }) + phoneNumber?: string; + + @ApiPropertyOptional({ + description: 'Verified date of birth from Fayda, ISO yyyy-MM-dd (VERIFY flow).', + }) + birthdate?: string; + + @ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' }) + gender?: string; } export class VerifaydaCallbackDto { 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 index e4b8cb790..cfd7c51bb 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts @@ -96,13 +96,12 @@ describe('VerifaydaService (OIDC, client-callback)', () => { prisma.faydaVerificationSession.create.mockResolvedValue({}); const url = await service.startVerification({ - purpose: 'PURCHASE', + purpose: 'VERIFY', userId: 'user-1', - saveToAccount: true, }); const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data; - expect(created.purpose).toBe('PURCHASE'); + expect(created.purpose).toBe('VERIFY'); expect(created.platform).toBe('WEB'); expect(typeof created.state).toBe('string'); expect(typeof created.codeVerifier).toBe('string'); @@ -139,7 +138,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => { jwt, ); await expect( - disabledService.startVerification({ purpose: 'PURCHASE' }), + disabledService.startVerification({ purpose: 'VERIFY' }), ).rejects.toMatchObject({ status: 503 }); }); }); @@ -150,14 +149,12 @@ describe('VerifaydaService (OIDC, client-callback)', () => { id: 'session-1', state: 'state-abc', codeVerifier: 'verifier-xyz', - purpose: 'PURCHASE', + purpose: 'VERIFY', platform: 'WEB', - saveToAccount: false, status: 'PENDING', errorCode: null, errorDescription: null, userId: null, - bookingId: null, expiresAt: new Date(Date.now() + 60_000), ...overrides, }; @@ -209,18 +206,16 @@ describe('VerifaydaService (OIDC, client-callback)', () => { }); }); - describe('completeVerification — PURCHASE', () => { + describe('completeVerification — VERIFY', () => { function pendingSession(overrides: Partial = {}) { return { id: 'session-1', state: 'state-abc', codeVerifier: 'verifier-xyz', - purpose: 'PURCHASE', + purpose: 'VERIFY', platform: 'WEB', - saveToAccount: false, status: 'PENDING', userId: null, - bookingId: null, expiresAt: new Date(Date.now() + 60_000), ...overrides, }; @@ -238,19 +233,23 @@ describe('VerifaydaService (OIDC, client-callback)', () => { (global as any).fetch = jest.fn(() => Promise.resolve(queue.shift())); } - it('stamps the booking seats and returns { verified, fullName }', async () => { - prisma.faydaVerificationSession.findUnique.mockResolvedValue( - pendingSession({ bookingId: 'booking-1' }), - ); + it('returns the verified identity attributes and writes no domain rows', async () => { + prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession()); 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' }), + JSON.stringify({ + sub: 'fayda-sub-1', + name: 'Test User', + email: 'test@example.com', + phone_number: '+251911000000', + birthdate: '1990-05-01', + gender: 'Male', + }), }, ); @@ -260,65 +259,17 @@ describe('VerifaydaService (OIDC, client-callback)', () => { }); expect(result).toMatchObject({ - purpose: 'PURCHASE', + purpose: 'VERIFY', verified: true, fullName: 'Test User', + email: 'test@example.com', + phoneNumber: '+251911000000', + birthdate: '1990-05-01', + gender: 'Male', }); expect(result.token).toBeUndefined(); - expect(prisma.bookingSeat.updateMany).toHaveBeenCalledWith({ - where: { bookingId: 'booking-1' }, - data: expect.objectContaining({ faydaSub: 'fayda-sub-1' }), - }); - }); - - it('saves to the User account 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 result = await service.completeVerification({ - code: 'authcode', - state: 'state-abc', - }); - - expect(result.verified).toBe(true); - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: 'user-1' }, - data: expect.objectContaining({ faydaVerified: true, faydaSub: 'fayda-sub-2' }), - }); - }); - - it('throws identity_conflict (409) when faydaSub belongs to another user', async () => { - prisma.faydaVerificationSession.findUnique.mockResolvedValue( - pendingSession({ userId: 'user-1', saveToAccount: true }), - ); - prisma.user.findFirst.mockResolvedValue({ id: 'other-user' }); - prisma.faydaVerificationSession.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-3', name: 'Test User' }), - }, - ); - - await expect( - service.completeVerification({ code: 'authcode', state: 'state-abc' }), - ).rejects.toMatchObject({ status: 409 }); + expect(result.user).toBeUndefined(); + expect(prisma.bookingSeat.updateMany).not.toHaveBeenCalled(); expect(prisma.user.update).not.toHaveBeenCalled(); }); @@ -353,11 +304,8 @@ describe('VerifaydaService (OIDC, client-callback)', () => { }); it('falls back to localized name (name#en) when name is missing', async () => { - prisma.faydaVerificationSession.findUnique.mockResolvedValue( - pendingSession({ bookingId: 'booking-2' }), - ); + prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession()); prisma.faydaVerificationSession.update.mockResolvedValue({}); - prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 }); mockFetchSequence( { json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) }, @@ -377,9 +325,6 @@ describe('VerifaydaService (OIDC, client-callback)', () => { state: 'state-abc', }); expect(result.fullName).toBe('English Name'); - expect(prisma.bookingSeat.updateMany.mock.calls[0][0].data.faydaVerifiedName).toBe( - 'English Name', - ); }); }); @@ -391,10 +336,8 @@ describe('VerifaydaService (OIDC, client-callback)', () => { codeVerifier: 'verifier-xyz', purpose: 'LOGIN', platform: 'WEB', - saveToAccount: false, status: 'PENDING', userId: null, - bookingId: null, expiresAt: new Date(Date.now() + 60_000), ...overrides, }; 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 7a929c3c5..f1340c87d 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts @@ -49,8 +49,6 @@ export interface StartVerificationInput { purpose: VerifaydaPurpose; platform?: FaydaPlatform; userId?: string; - bookingId?: string; - saveToAccount?: boolean; } export interface FaydaUserSummary { @@ -63,7 +61,8 @@ export interface FaydaUserSummary { /** * Result of completing a verification. `verified` is always true on success. - * LOGIN additionally returns a JWT + user; PURCHASE returns the verified name. + * LOGIN additionally returns a JWT + user; VERIFY returns the verified identity + * attributes (name, email, phone, dob, gender) for the caller to consume. */ export interface CompleteVerificationResult { purpose: VerifaydaPurpose; @@ -71,6 +70,10 @@ export interface CompleteVerificationResult { token?: string; user?: FaydaUserSummary; fullName?: string; + email?: string; + phoneNumber?: string; + birthdate?: string; + gender?: string; } @Injectable() @@ -134,15 +137,13 @@ export class VerifaydaService { codeVerifier, purpose: input.purpose, platform: input.platform ?? 'WEB', - saveToAccount: input.saveToAccount ?? false, userId: input.userId ?? null, - bookingId: input.bookingId ?? null, expiresAt, }, }); this.logger.log( - `Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'} bookingId=${input.bookingId ?? 'none'}`, + `Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'}`, ); return this.buildAuthorizationUrl({ state, codeChallenge }); @@ -206,17 +207,22 @@ export class VerifaydaService { } let result: CompleteVerificationResult; - if (session.purpose === 'PURCHASE') { - await this.handlePurchaseSuccess(session, normalized); - result = { - purpose: 'PURCHASE', - verified: true, - fullName: normalized.fullName, - }; - } else { + if (session.purpose === 'LOGIN') { const { userId } = await this.handleLoginSuccess(normalized); const login = await this.issueLoginToken(userId); result = { purpose: 'LOGIN', verified: true, ...login }; + } else { + // VERIFY — prove identity and hand the verified attributes back to the + // caller. No domain writes; the session row tracks status as usual. + result = { + purpose: 'VERIFY', + verified: true, + fullName: normalized.fullName, + email: normalized.email, + phoneNumber: normalized.phoneNumber, + birthdate: normalized.birthdate, + gender: normalized.gender, + }; } await this.prisma.faydaVerificationSession.update({ @@ -413,49 +419,6 @@ export class VerifaydaService { }; } - 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, - }, - }); - } - } - /** * Resolves the User for a LOGIN flow and returns its id (the caller mints the * JWT via {@link issueLoginToken}). Resolution order: diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts index 7c7335c34..450bfaff5 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts @@ -1,4 +1,4 @@ -export type VerifaydaPurpose = 'LOGIN' | 'PURCHASE'; +export type VerifaydaPurpose = 'LOGIN' | 'VERIFY'; export interface FaydaTokenResponse { access_token: string;