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 26108a452..901118389 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts @@ -57,6 +57,14 @@ export class CompleteVerificationResultDto { agentId?: string; }; + @ApiPropertyOptional({ + description: + 'eSignet subject identifier for the verified individual (VERIFY flow). A PSUT — ' + + 'pairwise and stable per client_id, never the FIN. The booking flow compares it across ' + + 'passengers so one Fayda identity cannot verify more than one passenger on a booking.', + }) + faydaSub?: string; + @ApiPropertyOptional({ description: 'Verified full name from Fayda (VERIFY flow).' }) fullName?: string; 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 df6cfc21d..42376d4a1 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts @@ -70,11 +70,19 @@ export interface FaydaUserSummary { /** * Result of completing a verification. `verified` is always true on success. * LOGIN additionally returns a JWT + user; VERIFY returns the verified identity - * attributes (name, email, phone, dob, gender) for the caller to consume. + * attributes (name, email, phone, dob, gender, faydaSub) for the caller to consume. */ export interface CompleteVerificationResult { purpose: VerifaydaPurpose; verified: boolean; + /** + * eSignet subject identifier for the verified individual. This is a PSUT — + * pairwise and stable per `client_id`, never the FIN — so it is safe to hand + * to the browser, and it is the same value `/passengers/me` already returns. + * The booking flow uses it to stop one Fayda identity from verifying more + * than one passenger on the same booking. + */ + faydaSub?: string; token?: string; refreshToken?: string; requiresPassword?: boolean; @@ -280,6 +288,7 @@ export class VerifaydaService { result = { purpose: 'VERIFY', verified: true, + faydaSub: normalized.sub, fullName: normalized.fullName, email: normalized.email, phoneNumber: normalized.phoneNumber, @@ -357,6 +366,13 @@ export class VerifaydaService { code_challenge_method: 'S256', acr_values: this.faydaConfig.acrValues, claims_locales: this.faydaConfig.claimsLocales, + // Force a fresh authentication instead of silently reusing the eSignet + // SSO session. A booking can carry several passengers, each of whom must + // verify with their OWN Fayda; without this, the second and third + // "Verify with Fayda" clicks round-trip in a couple of seconds and hand + // back the first passenger's identity, which the booking flow then has to + // reject with no way for the user to authenticate as the right person. + prompt: 'login', }); // Every claim is marked essential so eSignet shows them locked/pre-checked diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index 722ee75e2..ee681ab7e 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -761,6 +761,10 @@ function PassengersForm() { passportExpiryDate: stored.passportExpiryDate || '', passportIssuingAuthority: stored.passportIssuingAuthority || '', faydaVerified: stored.faydaVerified || false, + // Restore the identity that verified this passenger, so returning here from a + // later step (e.g. Back from /booking/seats) doesn't silently reopen the slot to + // an already-used Fayda. + faydaSub: stored.faydaSub || undefined, formExpanded: true, }; } @@ -873,13 +877,22 @@ function PassengersForm() { if (d?.verified) { const faydaSub: string | undefined = d.sub || d.faydaSub || d.fin; - // A single Fayda identity can't be reused across two different passengers. - const usedByOther = faydaSub && passengers.some( - (p, i) => i !== targetIndex && (p as any).faydaSub === faydaSub, - ); + // A single Fayda identity can't be reused across two different passengers. Read the + // live form rather than the `passengers` captured when this effect was created — the + // snapshot restore repopulates the array as the form initializes. + const currentPassengers = watch('passengers') || []; + const conflictIndex = faydaSub + ? currentPassengers.findIndex( + (p, i) => i !== targetIndex && (p as any)?.faydaSub === faydaSub, + ) + : -1; - if (usedByOther) { - setFaydaErrors((prev) => ({ ...prev, [targetIndex]: 'This Fayda identity is already linked to another passenger on this booking.' })); + if (conflictIndex >= 0) { + const conflictName = currentPassengers[conflictIndex]?.name?.trim(); + setFaydaErrors((prev) => ({ + ...prev, + [targetIndex]: `This Fayda ID has already been used to verify Passenger ${conflictIndex + 1}${conflictName ? ` (${conflictName})` : ''}. Each traveller must verify with their own Fayda.`, + })); setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'error' })); } else { // Convert "1980/12/01" → "1980-12-01" @@ -989,6 +1002,13 @@ function PassengersForm() { const emailVal = pick(passengerData.email, user.email); if (emailVal) setValue('passengers.0.email', emailVal); + // A logged-in, already-verified user occupies slot 0 without going through a fresh + // Fayda round trip, so the callback never records their sub on the form. Seed it from + // the account here, otherwise the duplicate-identity check has nothing to compare + // against and the account holder can re-use their own Fayda on passenger 2. + const accountFaydaSub = pick(passengerData.faydaSub, (user as any).faydaSub); + if (accountFaydaSub) setValue('passengers.0.faydaSub', accountFaydaSub); + if (mustVerifyFayda) { // Force the Fayda gate: leave name/DOB/gender empty and keep the form collapsed so the // "Verify with Fayda" screen is shown instead of an editable, pre-filled form. @@ -1103,6 +1123,13 @@ function PassengersForm() { gender: p.gender, nationality: p.nationality, nationalId: p.nationalId, + // Carry the verified Fayda identity into the booking store so the duplicate-identity + // check still has it if the user comes back to this page from /booking/seats. Without + // it the restore path below rebuilds each passenger without a sub, and one Fayda could + // then re-verify every passenger. Stripped server-side by the global ValidationPipe + // (whitelist: true), so sending it to /passengers/save-details is a no-op there. + faydaVerified: p.faydaVerified, + faydaSub: p.faydaSub, passportNumber: p.passportNumber, passportCountry: p.passportCountry, passportIssueDate: p.passportIssueDate,