diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index 75d2df681..295d6ab40 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -88,9 +88,19 @@ IAM_ENABLED=false IAM_API_URL=https://iam.tria-plc.com/api IAM_API_KEY= -# Verifayda 2.0 Configuration (Ethiopian National ID Verification) -VERIFAYDA_ENABLED=false -VERIFAYDA_API_URL=https://api.verifayda.gov.et/v2 -VERIFAYDA_API_KEY= +# --- VeriFayda 2.0 (eSignet) OIDC integration --- +FAYDA_ENABLED=true +FAYDA_CLIENT_ID= +FAYDA_AUTHORIZATION_ENDPOINT= +FAYDA_TOKEN_ENDPOINT= +FAYDA_USERINFO_ENDPOINT= +# Base64 of the RSA private JWK (JSON). Secret — never commit a real value. +FAYDA_PRIVATE_KEY_BASE64= +FAYDA_REDIRECT_URI= +# Optional (defaults shown) +FAYDA_SCOPE=openid profile email +FAYDA_ACR_VALUES=mosip:idp:acr:generated-code +FAYDA_CLAIMS_LOCALES=en am +FAYDA_SESSION_TTL_MINUTES=10 GITHUB_PACKAGE_TOKEN= \ No newline at end of file diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index ae10747f5..84d6b99fa 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -36,6 +36,7 @@ "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "jose": "^5.10.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "qrcode": "^1.5.3", diff --git a/apps/edr-passenger-api/prisma/migrations/20260525134854_add_fayda_oidc_verification/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260525134854_add_fayda_oidc_verification/migration.sql new file mode 100644 index 000000000..da38b0502 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260525134854_add_fayda_oidc_verification/migration.sql @@ -0,0 +1,55 @@ +/* + Warnings: + + - A unique constraint covering the columns `[faydaSub]` on the table `User` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "passenger"."BookingSeat" ADD COLUMN "faydaSub" TEXT, +ADD COLUMN "faydaVerifiedAt" TIMESTAMP(3), +ADD COLUMN "faydaVerifiedName" TEXT; + +-- AlterTable +ALTER TABLE "passenger"."User" ADD COLUMN "faydaSub" TEXT, +ADD COLUMN "faydaVerified" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "faydaVerifiedAt" TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "passenger"."FaydaVerificationSession" ( + "id" TEXT NOT NULL, + "state" TEXT NOT NULL, + "codeVerifier" TEXT NOT NULL, + "purpose" TEXT NOT NULL DEFAULT 'PURCHASE', + "saveToAccount" BOOLEAN NOT NULL DEFAULT false, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "errorCode" TEXT, + "errorDescription" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + "completedAt" TIMESTAMP(3), + "userId" TEXT, + "bookingId" TEXT, + + CONSTRAINT "FaydaVerificationSession_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "FaydaVerificationSession_state_key" ON "passenger"."FaydaVerificationSession"("state"); + +-- CreateIndex +CREATE INDEX "FaydaVerificationSession_userId_idx" ON "passenger"."FaydaVerificationSession"("userId"); + +-- CreateIndex +CREATE INDEX "FaydaVerificationSession_bookingId_idx" ON "passenger"."FaydaVerificationSession"("bookingId"); + +-- CreateIndex +CREATE INDEX "FaydaVerificationSession_state_idx" ON "passenger"."FaydaVerificationSession"("state"); + +-- CreateIndex +CREATE INDEX "FaydaVerificationSession_expiresAt_idx" ON "passenger"."FaydaVerificationSession"("expiresAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_faydaSub_key" ON "passenger"."User"("faydaSub"); + +-- AddForeignKey +ALTER TABLE "passenger"."FaydaVerificationSession" ADD CONSTRAINT "FaydaVerificationSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "passenger"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260527080312_add_platform_and_authcode/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260527080312_add_platform_and_authcode/migration.sql new file mode 100644 index 000000000..64c577ff5 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260527080312_add_platform_and_authcode/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "FaydaVerificationSession" ADD COLUMN "authCode" TEXT, +ADD COLUMN "platform" TEXT NOT NULL DEFAULT 'WEB'; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 342944edb..6cbefd93f 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -224,6 +224,11 @@ model User { lastLoginAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + faydaVerified Boolean @default(false) + faydaVerifiedAt DateTime? + faydaSub String? @unique + passenger Passenger? agent Agent? sessions Session[] @@ -232,6 +237,8 @@ model User { auditLogs AuditLog[] fraudAlerts FraudAlert[] + faydaVerificationSessions FaydaVerificationSession[] + @@schema("passenger") } @@ -515,6 +522,9 @@ model BookingSeat { passportCountry String? verifaydaVerified Boolean @default(false) verifaydaData Json? + faydaVerifiedAt DateTime? + faydaSub String? + faydaVerifiedName String? seatLabelSnapshot String? fareMinor Int? displayCurrency Currency? @@ -1256,3 +1266,32 @@ model SavedPassengerProfile { @@schema("passenger") } + +model FaydaVerificationSession { + id String @id @default(uuid()) + state String @unique + codeVerifier String + purpose String @default("PURCHASE") + platform String @default("WEB") // WEB | MOBILE — recorded for audit + saveToAccount Boolean @default(false) + status String @default("PENDING") + errorCode String? + errorDescription String? + authCode String? + createdAt DateTime @default(now()) + expiresAt DateTime + completedAt DateTime? + + userId String? + bookingId String? + + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([bookingId]) + @@index([state]) + @@index([expiresAt]) + + @@schema("passenger") +} + diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index d0dfed771..16178a1b4 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -13,6 +13,7 @@ import cbeConfig from './config/cbe.config'; import ebirrConfig from './config/ebirr.config'; import cardConfig from './config/card.config'; import waafiConfig from './config/waafi.config'; +import faydaConfig from './config/fayda.config'; import { AuthModule } from './modules/auth/auth.module'; import { StationsModule } from './modules/stations/stations.module'; import { FleetModule } from './modules/fleet/fleet.module'; @@ -36,12 +37,22 @@ import { ReportsModule } from './modules/reports/reports.module'; import { FraudModule } from './modules/fraud/fraud.module'; import { SeatClassesModule } from './modules/seat-classes/seat-classes.module'; import { FareEngineModule } from './modules/fare-engine/fare-engine.module'; +import { VerifaydaModule } from './modules/verifayda/verifayda.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [appConfig, dbConfig, telebirrConfig, cbeConfig, ebirrConfig, cardConfig, waafiConfig], + load: [ + appConfig, + dbConfig, + telebirrConfig, + cbeConfig, + ebirrConfig, + cardConfig, + waafiConfig, + faydaConfig, + ], }), ScheduleModule.forRoot(), EventEmitterModule.forRoot(), @@ -71,6 +82,7 @@ import { FareEngineModule } from './modules/fare-engine/fare-engine.module'; FraudModule, SeatClassesModule, FareEngineModule, + VerifaydaModule, ], }) export class AppModule implements NestModule { diff --git a/apps/edr-passenger-api/src/config/fayda.config.ts b/apps/edr-passenger-api/src/config/fayda.config.ts new file mode 100644 index 000000000..e0bce45c6 --- /dev/null +++ b/apps/edr-passenger-api/src/config/fayda.config.ts @@ -0,0 +1,118 @@ +import { registerAs } from '@nestjs/config'; + +export interface FaydaJwk { + kty: 'RSA'; + use?: string; + kid?: string; + alg?: string; + n: string; + e: string; + d: string; + p?: string; + q?: string; + dp?: string; + dq?: string; + qi?: string; +} + +export type FaydaPlatform = 'WEB' | 'MOBILE'; + +export interface FaydaConfig { + enabled: boolean; + clientId: string; + authorizationEndpoint: string; + tokenEndpoint: string; + userInfoEndpoint: string; + redirectUri: string; + privateJwk: FaydaJwk; + scope: string; + acrValues: string; + claimsLocales: string; + sessionTtlMinutes: number; +} + +const REQUIRED_VARS = [ + 'FAYDA_CLIENT_ID', + 'FAYDA_AUTHORIZATION_ENDPOINT', + 'FAYDA_TOKEN_ENDPOINT', + 'FAYDA_USERINFO_ENDPOINT', + 'FAYDA_PRIVATE_KEY_BASE64', +] as const; + +function decodePrivateJwk(base64: string): FaydaJwk { + let jwk: unknown; + try { + const json = Buffer.from(base64, 'base64').toString('utf8'); + jwk = JSON.parse(json); + } catch (err) { + throw new Error( + `FAYDA_PRIVATE_KEY_BASE64 is not valid Base64-encoded JSON: ${(err as Error).message}`, + ); + } + if (!jwk || typeof jwk !== 'object') { + throw new Error('FAYDA_PRIVATE_KEY_BASE64 must decode to a JSON object'); + } + const candidate = jwk as Partial; + if (candidate.kty !== 'RSA') { + throw new Error('FAYDA_PRIVATE_KEY_BASE64 JWK must have kty="RSA"'); + } + if (!candidate.n || !candidate.e || !candidate.d) { + throw new Error( + 'FAYDA_PRIVATE_KEY_BASE64 JWK is missing required RSA private-key fields (n, e, d)', + ); + } + return candidate as FaydaJwk; +} + +export default registerAs('fayda', (): FaydaConfig => { + const enabled = (process.env.FAYDA_ENABLED ?? 'false').toLowerCase() === 'true'; + const scope = process.env.FAYDA_SCOPE ?? 'openid profile email'; + const acrValues = process.env.FAYDA_ACR_VALUES ?? 'mosip:idp:acr:generated-code'; + const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am'; + const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10); + const redirectUri = process.env.FAYDA_REDIRECT_URI ?? ''; + if (!enabled) { + return { + enabled: false, + clientId: process.env.FAYDA_CLIENT_ID ?? '', + authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT ?? '', + tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT ?? '', + userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '', + redirectUri, + privateJwk: { kty: 'RSA', n: '', e: '', d: '' }, + scope, + acrValues, + claimsLocales, + sessionTtlMinutes: Number.isNaN(sessionTtl) || sessionTtl <= 0 ? 10 : sessionTtl, + }; + } + + const missing = REQUIRED_VARS.filter((name) => !process.env[name]); + if (missing.length > 0) { + throw new Error( + `Fayda integration is enabled (FAYDA_ENABLED=true) but the following env vars are missing: ${missing.join(', ')}`, + ); + } + if (!redirectUri) { + throw new Error( + 'Fayda integration is enabled but the redirect URI is missing: set FAYDA_REDIRECT_URI', + ); + } + if (Number.isNaN(sessionTtl) || sessionTtl <= 0) { + throw new Error('FAYDA_SESSION_TTL_MINUTES must be a positive integer'); + } + + return { + enabled: true, + clientId: process.env.FAYDA_CLIENT_ID!, + authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT!, + tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT!, + userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!, + redirectUri, + privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!), + scope, + acrValues, + claimsLocales, + sessionTtlMinutes: sessionTtl, + }; +}); diff --git a/apps/edr-passenger-api/src/modules/verifayda/optional-jwt.guard.ts b/apps/edr-passenger-api/src/modules/verifayda/optional-jwt.guard.ts new file mode 100644 index 000000000..5f5fac19b --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/optional-jwt.guard.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +/** + * Like {@link JwtGuard}, but never rejects the request. + * + * When a valid `Authorization: Bearer ` is present, `request.user` is + * populated from the JWT strategy (`{ userId, ... }`). When the token is + * missing or invalid, the request still proceeds with `request.user` + * undefined — the handler decides what to do. + * + * Used on `POST /fayda/verification/start`, which must work for both + * logged-in users (who can opt to save the verification to their account) + * and guests (anchored to a booking only). + */ +@Injectable() +export class OptionalJwtGuard extends AuthGuard('jwt') { + handleRequest(_err: unknown, user: TUser): TUser { + return (user ?? null) as TUser; + } +} diff --git a/apps/edr-passenger-api/src/modules/verifayda/utils/client-assertion.util.spec.ts b/apps/edr-passenger-api/src/modules/verifayda/utils/client-assertion.util.spec.ts new file mode 100644 index 000000000..9b4316fc7 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/utils/client-assertion.util.spec.ts @@ -0,0 +1,71 @@ +import { exportJWK, generateKeyPair, importJWK, jwtVerify, type JWK } from 'jose'; +import { generateClientAssertion } from './client-assertion.util'; + +describe('generateClientAssertion', () => { + let privateJwk: JWK; + let publicJwk: JWK; + + beforeAll(async () => { + const kp = await generateKeyPair('RS256', { extractable: true }); + privateJwk = await exportJWK(kp.privateKey); + publicJwk = await exportJWK(kp.publicKey); + }); + + it('produces a JWT verifiable with the matching public key', async () => { + const jwt = await generateClientAssertion({ + clientId: 'edr-passenger-test', + audience: 'https://esignet.example.com/token', + privateJwk, + }); + + const verifier = await importJWK(publicJwk, 'RS256'); + const { payload, protectedHeader } = await jwtVerify(jwt, verifier, { + issuer: 'edr-passenger-test', + subject: 'edr-passenger-test', + audience: 'https://esignet.example.com/token', + }); + + expect(protectedHeader.alg).toBe('RS256'); + expect(protectedHeader.typ).toBe('JWT'); + expect(payload.iss).toBe('edr-passenger-test'); + expect(payload.sub).toBe('edr-passenger-test'); + expect(payload.aud).toBe('https://esignet.example.com/token'); + expect(typeof payload.iat).toBe('number'); + expect(typeof payload.exp).toBe('number'); + }); + + it('defaults exp to 120 seconds after iat', async () => { + const jwt = await generateClientAssertion({ + clientId: 'c', + audience: 'https://a/token', + privateJwk, + }); + const verifier = await importJWK(publicJwk, 'RS256'); + const { payload } = await jwtVerify(jwt, verifier); + expect(payload.exp! - payload.iat!).toBe(120); + }); + + it('honors a custom expiresIn', async () => { + const jwt = await generateClientAssertion({ + clientId: 'c', + audience: 'https://a/token', + privateJwk, + expiresIn: '5m', + }); + const verifier = await importJWK(publicJwk, 'RS256'); + const { payload } = await jwtVerify(jwt, verifier); + expect(payload.exp! - payload.iat!).toBe(300); + }); + + it('fails verification against a wrong audience', async () => { + const jwt = await generateClientAssertion({ + clientId: 'c', + audience: 'https://a/token', + privateJwk, + }); + const verifier = await importJWK(publicJwk, 'RS256'); + await expect( + jwtVerify(jwt, verifier, { audience: 'https://other/token' }), + ).rejects.toThrow(); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/verifayda/utils/client-assertion.util.ts b/apps/edr-passenger-api/src/modules/verifayda/utils/client-assertion.util.ts new file mode 100644 index 000000000..dc3558ccc --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/utils/client-assertion.util.ts @@ -0,0 +1,22 @@ +import { SignJWT, importJWK, type JWK } from 'jose'; + +export interface GenerateClientAssertionInput { + clientId: string; + audience: string; + privateJwk: JWK; + expiresIn?: string; +} + +export async function generateClientAssertion( + input: GenerateClientAssertionInput, +): Promise { + const privateKey = await importJWK(input.privateJwk, 'RS256'); + return new SignJWT({}) + .setProtectedHeader({ alg: 'RS256', typ: 'JWT' }) + .setIssuer(input.clientId) + .setSubject(input.clientId) + .setAudience(input.audience) + .setIssuedAt() + .setExpirationTime(input.expiresIn ?? '2m') + .sign(privateKey); +} diff --git a/apps/edr-passenger-api/src/modules/verifayda/utils/pkce.util.spec.ts b/apps/edr-passenger-api/src/modules/verifayda/utils/pkce.util.spec.ts new file mode 100644 index 000000000..d359a07f2 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/utils/pkce.util.spec.ts @@ -0,0 +1,65 @@ +import { createHash } from 'crypto'; +import { + base64Url, + generateCodeChallenge, + generateCodeVerifier, + generateState, +} from './pkce.util'; + +describe('pkce.util', () => { + describe('base64Url', () => { + it('strips padding and replaces + and / with - and _', () => { + const input = Buffer.from([0xfb, 0xff, 0xbf, 0xfe]); + const out = base64Url(input); + expect(out).not.toMatch(/[+/=]/); + }); + }); + + describe('generateCodeVerifier', () => { + it('returns a base64url-safe string', () => { + expect(generateCodeVerifier()).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('produces unique values across calls', () => { + const a = generateCodeVerifier(); + const b = generateCodeVerifier(); + expect(a).not.toEqual(b); + }); + + it('produces at least 43 characters (RFC 7636 minimum)', () => { + expect(generateCodeVerifier().length).toBeGreaterThanOrEqual(43); + }); + }); + + describe('generateCodeChallenge', () => { + it('equals base64url(sha256(verifier))', () => { + const verifier = 'fixed-test-verifier'; + const expected = createHash('sha256') + .update(verifier) + .digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + expect(generateCodeChallenge(verifier)).toBe(expected); + }); + + it('is deterministic for the same verifier', () => { + const verifier = generateCodeVerifier(); + expect(generateCodeChallenge(verifier)).toBe(generateCodeChallenge(verifier)); + }); + + it('differs for different verifiers', () => { + expect(generateCodeChallenge('a')).not.toBe(generateCodeChallenge('b')); + }); + }); + + describe('generateState', () => { + it('returns a base64url-safe string', () => { + expect(generateState()).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('produces unique values across calls', () => { + expect(generateState()).not.toEqual(generateState()); + }); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/verifayda/utils/pkce.util.ts b/apps/edr-passenger-api/src/modules/verifayda/utils/pkce.util.ts new file mode 100644 index 000000000..89e9437d2 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/utils/pkce.util.ts @@ -0,0 +1,21 @@ +import { createHash, randomBytes } from 'crypto'; + +export function base64Url(buffer: Buffer): string { + return buffer + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +export function generateCodeVerifier(): string { + return base64Url(randomBytes(64)); +} + +export function generateCodeChallenge(codeVerifier: string): string { + return base64Url(createHash('sha256').update(codeVerifier).digest()); +} + +export function generateState(): string { + return base64Url(randomBytes(32)); +} diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts new file mode 100644 index 000000000..f1eb25e8e --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts @@ -0,0 +1,111 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { JwtGuard } from '../../common/jwt.guard'; +import { OptionalJwtGuard } from './optional-jwt.guard'; +import { + CompleteVerificationResultDto, + StartVerificationDto, + VerifaydaCallbackDto, + VerificationStatusDto, +} from './verifayda.dto'; +import { VerifaydaService } from './verifayda.service'; + +/** Shape the JWT strategy puts on `request.user` (see common/jwt.strategy.ts). */ +interface AuthedUser { + userId: string; + email?: string; + role?: string; + passengerId?: string; +} + +/** Minimal slices of the Express req we touch (avoids a hard dependency on + * `@types/express`, which isn't resolved in this package). */ +interface RequestWithOptionalUser { + user?: AuthedUser; +} +interface RequestWithUser { + user: AuthedUser; +} + +@ApiTags('Fayda Verification') +@Controller('fayda/verification') +export class VerifaydaController { + constructor(private readonly service: VerifaydaService) {} + + @Post('start') + @HttpCode(HttpStatus.OK) + @UseGuards(OptionalJwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + 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. +- 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({ + description: 'Authorize URL the frontend should redirect the user to.', + schema: { + example: { + authorizationUrl: + 'https://esignet.example.com/authorize?client_id=...&state=...&code_challenge=...', + }, + }, + }) + async start( + @Body() dto: StartVerificationDto, + @Req() req: RequestWithOptionalUser, + ): Promise<{ authorizationUrl: string }> { + const authorizationUrl = await this.service.startVerification({ + purpose: dto.purpose ?? 'PURCHASE', + platform: dto.platform ?? 'WEB', + userId: req.user?.userId, + bookingId: dto.bookingId, + saveToAccount: dto.saveToAccount, + }); + return { authorizationUrl }; + } + + @Get('complete') + @ApiOperation({ + summary: 'Complete a verification (Fayda redirect / client callback lands here)', + description: `This is the registered Fayda \`redirect_uri\`. Fayda redirects the browser here with \`?code&state\``, + }) + @ApiOkResponse({ type: CompleteVerificationResultDto }) + async complete( + @Query() dto: VerifaydaCallbackDto, + ): Promise { + return this.service.completeVerification(dto); + } + + @Get('status') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: "Get the current user's Fayda verification status", + description: + 'Returns whether the authenticated user has linked a verified Fayda identity to their account, when, and the name on file.', + }) + @ApiOkResponse({ type: VerificationStatusDto }) + async status( + @Req() req: RequestWithUser, + ): Promise { + return this.service.getVerificationStatus(req.user.userId); + } +} 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..005a3e517 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts @@ -0,0 +1,78 @@ +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; + + @ApiPropertyOptional({ + enum: ['WEB', 'MOBILE'], + default: 'WEB', + description: + 'Client platform. Decides where /callback redirects on completion: a web https URL (WEB) or a custom-scheme deep link the Flutter app intercepts (MOBILE).', + }) + @IsOptional() + @IsIn(['WEB', 'MOBILE']) + platform?: 'WEB' | 'MOBILE'; +} + +export class CompleteVerificationResultDto { + @ApiProperty({ enum: ['LOGIN', 'PURCHASE'] }) + purpose: 'LOGIN' | 'PURCHASE'; + + @ApiProperty() verified: boolean; + + @ApiPropertyOptional({ description: 'JWT (LOGIN flow only).' }) + token?: string; + + @ApiPropertyOptional({ + description: 'Authenticated user summary (LOGIN flow only; same shape as /auth/login).', + }) + user?: { + id: string; + email: string; + role: string; + passengerId?: string; + agentId?: string; + }; + + @ApiPropertyOptional({ + description: 'Verified full name from Fayda (PURCHASE flow).', + }) + fullName?: string; +} + +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.module.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts index c14ba3f1f..d850b1dbf 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts @@ -1,9 +1,14 @@ import { Module } from '@nestjs/common'; +import { VerifaydaController } from './verifayda.controller'; import { VerifaydaService } from './verifayda.service'; import { PrismaModule } from '../../common/prisma.module'; +import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [PrismaModule], + // AuthModule re-exports JwtModule, giving us JwtService (same secret/expiry + // config as /auth/login) to mint tokens for the LOGIN flow. + imports: [PrismaModule, AuthModule], + controllers: [VerifaydaController], providers: [VerifaydaService], exports: [VerifaydaService], }) 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..e4b8cb790 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts @@ -0,0 +1,569 @@ +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { exportJWK, generateKeyPair, type JWK } from 'jose'; +import { PrismaService } from '../../common/prisma.service'; +import { FaydaConfig } from '../../config/fayda.config'; +import { VerifaydaService } from './verifayda.service'; + +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(), + create: jest.fn(), + update: jest.fn(), + }, + passenger: { create: jest.fn() }, + loyaltyAccount: { create: jest.fn() }, + walletAccount: { create: jest.fn() }, + userPreferences: { create: jest.fn() }, + verifaydaVerification: { create: jest.fn() }, + }; +} + +function buildJwtMock(): jest.Mocked { + return { + sign: jest.fn(() => 'signed.jwt.token'), + } as unknown as jest.Mocked; +} + +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: 'http://localhost:4000/fayda/verification/complete', + privateJwk: { kty: 'RSA', n: '', e: '', d: '' }, + 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, client-callback)', () => { + let prisma: ReturnType; + let jwt: jest.Mocked; + 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(); + jwt = buildJwtMock(); + const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] }); + service = new VerifaydaService( + buildConfigService(cfg), + prisma as unknown as PrismaService, + jwt, + ); + (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, + }); + + const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data; + expect(created.purpose).toBe('PURCHASE'); + expect(created.platform).toBe('WEB'); + 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('code_challenge_method')).toBe('S256'); + expect(parsed.searchParams.get('redirect_uri')).toBe( + 'http://localhost:4000/fayda/verification/complete', + ); + expect(parsed.searchParams.get('state')).toBe(created.state); + }); + + it('uses the same single redirect_uri regardless of platform (platform is only recorded)', async () => { + prisma.faydaVerificationSession.create.mockResolvedValue({}); + + const url = await service.startVerification({ + purpose: 'LOGIN', + platform: 'MOBILE', + }); + + const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data; + expect(created.platform).toBe('MOBILE'); + expect(new URL(url).searchParams.get('redirect_uri')).toBe( + 'http://localhost:4000/fayda/verification/complete', + ); + }); + + it('throws ServiceUnavailable when fayda integration is disabled', async () => { + const disabledService = new VerifaydaService( + buildConfigService(buildConfig({ enabled: false })), + prisma as unknown as PrismaService, + jwt, + ); + await expect( + disabledService.startVerification({ purpose: 'PURCHASE' }), + ).rejects.toMatchObject({ status: 503 }); + }); + }); + + describe('completeVerification — validation', () => { + function pendingSession(overrides: Partial = {}) { + return { + id: 'session-1', + state: 'state-abc', + codeVerifier: 'verifier-xyz', + purpose: 'PURCHASE', + platform: 'WEB', + saveToAccount: false, + status: 'PENDING', + errorCode: null, + errorDescription: null, + userId: null, + bookingId: null, + expiresAt: new Date(Date.now() + 60_000), + ...overrides, + }; + } + + it('throws and marks failed when callback carries an error', async () => { + prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 }); + await expect( + service.completeVerification({ + error: 'access_denied', + error_description: 'user cancelled', + state: 'state-abc', + }), + ).rejects.toMatchObject({ status: 400 }); + expect(prisma.faydaVerificationSession.updateMany).toHaveBeenCalled(); + }); + + it('throws FAYDA_MISSING_PARAMETERS when code/state absent', async () => { + await expect(service.completeVerification({})).rejects.toMatchObject({ + status: 400, + }); + }); + + it('throws FAYDA_INVALID_STATE for unknown state', async () => { + prisma.faydaVerificationSession.findUnique.mockResolvedValue(null); + await expect( + service.completeVerification({ code: 'c', state: 'bogus' }), + ).rejects.toMatchObject({ status: 400 }); + }); + + it('throws FAYDA_INVALID_STATE for a non-pending session', async () => { + prisma.faydaVerificationSession.findUnique.mockResolvedValue( + pendingSession({ status: 'COMPLETED' }), + ); + await expect( + service.completeVerification({ code: 'c', state: 'state-abc' }), + ).rejects.toMatchObject({ status: 400 }); + }); + + it('throws FAYDA_SESSION_EXPIRED and marks failed for an expired session', async () => { + prisma.faydaVerificationSession.findUnique.mockResolvedValue( + pendingSession({ expiresAt: new Date(Date.now() - 1000) }), + ); + prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 }); + await expect( + service.completeVerification({ code: 'c', state: 'state-abc' }), + ).rejects.toMatchObject({ status: 400 }); + expect(prisma.faydaVerificationSession.updateMany).toHaveBeenCalled(); + }); + }); + + describe('completeVerification — PURCHASE', () => { + function pendingSession(overrides: Partial = {}) { + return { + id: 'session-1', + state: 'state-abc', + codeVerifier: 'verifier-xyz', + purpose: 'PURCHASE', + platform: 'WEB', + saveToAccount: false, + status: 'PENDING', + 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('stamps the booking seats and returns { verified, fullName }', 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' }), + }, + ); + + const result = await service.completeVerification({ + code: 'authcode', + state: 'state-abc', + }); + + expect(result).toMatchObject({ + purpose: 'PURCHASE', + verified: true, + fullName: 'Test User', + }); + 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(prisma.user.update).not.toHaveBeenCalled(); + }); + + it('throws 502 when the token endpoint returns 4xx', async () => { + prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession()); + prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 }); + mockFetchSequence({ + ok: false, + status: 400, + text: async () => '{"error":"invalid_assertion"}', + }); + + await expect( + service.completeVerification({ code: 'authcode', state: 'state-abc' }), + ).rejects.toMatchObject({ status: 502 }); + }); + + it('throws 502 when userinfo is an unsupported format', async () => { + prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession()); + prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 }); + mockFetchSequence( + { json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) }, + { + headers: new Headers({ 'content-type': 'text/plain' }), + text: async () => 'not-a-jwt-not-a-json', + }, + ); + + await expect( + service.completeVerification({ code: 'authcode', state: 'state-abc' }), + ).rejects.toMatchObject({ status: 502 }); + }); + + 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', + }), + }, + ); + + const result = await service.completeVerification({ + code: 'c', + state: 'state-abc', + }); + expect(result.fullName).toBe('English Name'); + expect(prisma.bookingSeat.updateMany.mock.calls[0][0].data.faydaVerifiedName).toBe( + 'English Name', + ); + }); + }); + + describe('completeVerification — LOGIN', () => { + function loginSession(overrides: Partial = {}) { + return { + id: 'login-session', + state: 'state-login', + codeVerifier: 'verifier-xyz', + purpose: 'LOGIN', + platform: 'WEB', + saveToAccount: false, + status: 'PENDING', + userId: null, + bookingId: null, + expiresAt: new Date(Date.now() + 60_000), + ...overrides, + }; + } + + function mockLoginFetch(userInfo: Record) { + const queue = [ + { + ok: true, + status: 200, + json: async () => ({ access_token: 'tok', token_type: 'Bearer' }), + text: async () => '', + headers: new Headers({ 'content-type': 'application/json' }), + }, + { + ok: true, + status: 200, + json: async () => ({}), + text: async () => JSON.stringify(userInfo), + headers: new Headers({ 'content-type': 'application/json' }), + }, + ]; + (global as any).fetch = jest.fn(() => Promise.resolve(queue.shift())); + } + + /** user.findUnique answers the faydaSub lookup and the issueLoginToken id lookup. */ + function mockUserFindUnique(bySub: any, fullUser: any) { + prisma.user.findUnique.mockImplementation(async (args: any) => { + if (args?.where?.faydaSub !== undefined) return bySub; + if (args?.where?.id !== undefined) return fullUser; + return null; + }); + } + + beforeEach(() => { + prisma.faydaVerificationSession.findUnique.mockResolvedValue(loginSession()); + }); + + it('creates a new user when no match and returns { token, user }', async () => { + const fullUser = { + id: 'new-user', + email: 'new@example.com', + role: 'PASSENGER', + passenger: { id: 'p-new' }, + agent: null, + }; + mockUserFindUnique(null, fullUser); + prisma.user.findFirst.mockResolvedValue(null); + prisma.user.create.mockResolvedValue({ id: 'new-user' }); + prisma.passenger.create.mockResolvedValue({ id: 'p-new' }); + prisma.loyaltyAccount.create.mockResolvedValue({}); + prisma.walletAccount.create.mockResolvedValue({}); + prisma.userPreferences.create.mockResolvedValue({}); + prisma.faydaVerificationSession.update.mockResolvedValue({}); + + mockLoginFetch({ sub: 'login-sub-1', name: 'New Person', email: 'new@example.com' }); + + const result = await service.completeVerification({ + code: 'c', + state: 'state-login', + }); + + expect(result).toMatchObject({ + purpose: 'LOGIN', + verified: true, + token: 'signed.jwt.token', + user: { id: 'new-user', passengerId: 'p-new' }, + }); + expect(prisma.user.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + faydaSub: 'login-sub-1', + faydaVerified: true, + email: 'new@example.com', + }), + }), + ); + expect(prisma.passenger.create).toHaveBeenCalled(); + expect(jwt.sign).toHaveBeenCalledWith( + expect.objectContaining({ sub: 'new-user', passengerId: 'p-new' }), + ); + }); + + it('logs in an existing user already linked by faydaSub', async () => { + const fullUser = { + id: 'known-user', + email: 'k@example.com', + role: 'PASSENGER', + passenger: { id: 'p-k' }, + agent: null, + }; + mockUserFindUnique({ id: 'known-user' }, fullUser); + prisma.faydaVerificationSession.update.mockResolvedValue({}); + + mockLoginFetch({ sub: 'login-sub-2', name: 'Known' }); + + const result = await service.completeVerification({ + code: 'c', + state: 'state-login', + }); + + expect(result.user?.id).toBe('known-user'); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + + it('links Fayda to an existing account matched by email', async () => { + const fullUser = { + id: 'acc-1', + email: 'match@example.com', + role: 'PASSENGER', + passenger: { id: 'p-1' }, + agent: null, + }; + mockUserFindUnique(null, fullUser); + prisma.user.findFirst.mockResolvedValue({ id: 'acc-1', faydaSub: null }); + prisma.user.update.mockResolvedValue({}); + prisma.faydaVerificationSession.update.mockResolvedValue({}); + + mockLoginFetch({ sub: 'login-sub-3', email: 'match@example.com' }); + + const result = await service.completeVerification({ + code: 'c', + state: 'state-login', + }); + + expect(result.user?.id).toBe('acc-1'); + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'acc-1' }, + data: expect.objectContaining({ faydaSub: 'login-sub-3' }), + }), + ); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + + it('throws identity_conflict (409) when matched account has a different faydaSub', async () => { + mockUserFindUnique(null, null); + prisma.user.findFirst.mockResolvedValue({ id: 'acc-2', faydaSub: 'someone-else' }); + prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 }); + + mockLoginFetch({ sub: 'login-sub-4', email: 'match@example.com' }); + + await expect( + service.completeVerification({ code: 'c', state: 'state-login' }), + ).rejects.toMatchObject({ status: 409 }); + expect(prisma.user.update).not.toHaveBeenCalled(); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + }); + + 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..7a929c3c5 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,35 @@ -import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + Logger, + ServiceUnavailableException, + UnauthorizedException, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { PrismaService } from '../../common/prisma.service'; +import { JwtService } from '@nestjs/jwt'; import axios, { AxiosInstance } from 'axios'; +import * as bcrypt from 'bcrypt'; +import { randomBytes } from 'crypto'; +import { PrismaService } from '../../common/prisma.service'; +import { FaydaConfig, FaydaPlatform } 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 +45,554 @@ export interface VerifaydaVerificationResult { failureReason?: string; } +export interface StartVerificationInput { + purpose: VerifaydaPurpose; + platform?: FaydaPlatform; + userId?: string; + bookingId?: string; + saveToAccount?: boolean; +} + +export interface FaydaUserSummary { + id: string; + email: string; + role: string; + passengerId?: string; + agentId?: string; +} + +/** + * Result of completing a verification. `verified` is always true on success. + * LOGIN additionally returns a JWT + user; PURCHASE returns the verified name. + */ +export interface CompleteVerificationResult { + purpose: VerifaydaPurpose; + verified: boolean; + token?: string; + user?: FaydaUserSummary; + fullName?: string; +} + @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, + private readonly jwt: JwtService, ) { - 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, + 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'}`, + ); + + return this.buildAuthorizationUrl({ state, codeChallenge }); + } + + + async completeVerification( + 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, + ); + } + throw new BadRequestException({ + code: 'FAYDA_AUTH_ERROR', + message: query.error, + description: query.error_description, + }); + } + + if (!query.code || !query.state) { + throw new BadRequestException({ + code: 'FAYDA_MISSING_PARAMETERS', + message: 'code and state are required', + }); + } + + const session = await this.prisma.faydaVerificationSession.findUnique({ + where: { state: query.state }, + }); + if (!session || session.status !== 'PENDING') { + this.logger.warn('Fayda complete with unknown or non-pending state'); + throw new BadRequestException({ + code: 'FAYDA_INVALID_STATE', + message: 'Verification session is invalid or already used', + }); + } + if (session.expiresAt.getTime() < Date.now()) { + await this.markSessionFailed(query.state, 'session_expired'); + throw new BadRequestException({ + code: 'FAYDA_SESSION_EXPIRED', + message: 'Verification session has expired; start again', + }); + } + + 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'); + } + + let result: CompleteVerificationResult; + if (session.purpose === 'PURCHASE') { + await this.handlePurchaseSuccess(session, normalized); + result = { + purpose: 'PURCHASE', + verified: true, + fullName: normalized.fullName, + }; + } else { + const { userId } = await this.handleLoginSuccess(normalized); + const login = await this.issueLoginToken(userId); + result = { purpose: 'LOGIN', verified: true, ...login }; + } + + 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} platform=${session.platform}`, + ); + return result; + } 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, + ); + throw err; + } + } + + /** Loads a user (+ relations) and mints the same JWT shape as `/auth/login`. */ + private async issueLoginToken( + userId: string, + ): Promise<{ token: string; user: FaydaUserSummary }> { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { passenger: true, agent: true }, + }); + if (!user) { + // Should not happen — we just resolved/created this user. + throw new UnauthorizedException({ + code: 'FAYDA_LOGIN_FAILED', + message: 'Could not load the verified user', + }); + } + + const summary: FaydaUserSummary = { + id: user.id, + email: user.email, + role: user.role, + passengerId: user.passenger?.id, + agentId: user.agent?.id, + }; + const token = this.jwt.sign({ + sub: summary.id, + email: summary.email, + role: summary.role, + passengerId: summary.passengerId, + agentId: summary.agentId, + }); + + this.logger.log(`Fayda login issued token for user ${user.id}`); + return { token, user: summary }; + } + + 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, + }, + }); + } + } + + /** + * Resolves the User for a LOGIN flow and returns its id (the caller mints the + * JWT via {@link issueLoginToken}). Resolution order: + * 1. Existing user already linked to this Fayda `sub`. + * 2. Existing account whose email/phone matches — linked to this `sub`. + * 3. Otherwise a fresh Fayda-backed account is created. + */ + private async handleLoginSuccess( + normalized: NormalizedFaydaUserInfo, + ): Promise<{ userId: string }> { + let userId: string; + + const bySub = await this.prisma.user.findUnique({ + where: { faydaSub: normalized.sub }, + select: { id: true }, + }); + + if (bySub) { + userId = bySub.id; + } else { + const matchers: Array<{ email?: string; phone?: string }> = []; + if (normalized.email) matchers.push({ email: normalized.email }); + if (normalized.phoneNumber) matchers.push({ phone: normalized.phoneNumber }); + + const existing = matchers.length + ? await this.prisma.user.findFirst({ + where: { OR: matchers }, + select: { id: true, faydaSub: true }, + }) + : null; + + if (existing) { + if (existing.faydaSub && existing.faydaSub !== normalized.sub) { + // The matched account is already tied to a different Fayda identity. + throw new FaydaIdentityConflictException(); + } + await this.prisma.user.update({ + where: { id: existing.id }, + data: { + faydaSub: normalized.sub, + faydaVerified: true, + faydaVerifiedAt: new Date(), + }, + }); + userId = existing.id; + this.logger.log(`Fayda login linked existing user ${existing.id}`); + } else { + userId = await this.createFaydaUser(normalized); + this.logger.log(`Fayda login created new user ${userId}`); + } + } + + return { userId }; + } + + /** + * Creates a Fayda-backed User plus the same satellite rows registration makes + * (Passenger, LoyaltyAccount, WalletAccount, UserPreferences). + * + * The user has no password — `passwordHash` is set to a bcrypt of random bytes + * so password login is impossible; they authenticate only via Fayda. When + * Fayda doesn't supply an email/phone, a deterministic placeholder derived from + * the (unique) `sub` keeps the NOT NULL + unique columns satisfied. + */ + private async createFaydaUser( + normalized: NormalizedFaydaUserInfo, + ): Promise { + const passwordHash = await bcrypt.hash( + randomBytes(32).toString('hex'), + 10, + ); + const email = normalized.email ?? `fayda_${normalized.sub}@users.fayda.local`; + const phone = normalized.phoneNumber ?? `fayda:${normalized.sub}`; + const fullName = normalized.fullName ?? 'Fayda User'; + + const user = await this.prisma.user.create({ + data: { + fullName, + email, + phone, + passwordHash, + faydaVerified: true, + faydaVerifiedAt: new Date(), + faydaSub: normalized.sub, + }, + select: { id: true }, + }); + const passenger = await this.prisma.passenger.create({ + data: { userId: user.id }, + select: { id: true }, + }); + await this.prisma.loyaltyAccount.create({ + data: { passengerId: passenger.id }, + }); + await this.prisma.walletAccount.create({ + data: { passengerId: passenger.id }, + }); + await this.prisma.userPreferences.create({ data: { userId: user.id } }); + + return user.id; + } + + 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 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 +606,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 +630,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 +657,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; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30f7e543d..e528988eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -320,6 +320,9 @@ importers: class-validator: specifier: ^0.14.0 version: 0.14.4 + jose: + specifier: ^5.10.0 + version: 5.10.0 passport: specifier: ^0.7.0 version: 0.7.0