diff --git a/apps/edr-passenger-api/prisma/migrations/20260526110803_add_fayda_login_authcode_and_platform/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260526110803_add_fayda_login_authcode_and_platform/migration.sql new file mode 100644 index 000000000..661ddbe85 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260526110803_add_fayda_login_authcode_and_platform/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - A unique constraint covering the columns `[authCode]` on the table `FaydaVerificationSession` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "FaydaVerificationSession" ADD COLUMN "authCode" TEXT, +ADD COLUMN "platform" TEXT NOT NULL DEFAULT 'WEB'; + +-- CreateIndex +CREATE UNIQUE INDEX "FaydaVerificationSession_authCode_key" ON "FaydaVerificationSession"("authCode"); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index c72e69ef4..24ff8a192 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -1261,6 +1261,7 @@ model FaydaVerificationSession { 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? diff --git a/apps/edr-passenger-api/src/config/fayda.config.ts b/apps/edr-passenger-api/src/config/fayda.config.ts index 6e317760b..e0bce45c6 100644 --- a/apps/edr-passenger-api/src/config/fayda.config.ts +++ b/apps/edr-passenger-api/src/config/fayda.config.ts @@ -15,6 +15,8 @@ export interface FaydaJwk { qi?: string; } +export type FaydaPlatform = 'WEB' | 'MOBILE'; + export interface FaydaConfig { enabled: boolean; clientId: string; @@ -23,8 +25,6 @@ export interface FaydaConfig { userInfoEndpoint: string; redirectUri: string; privateJwk: FaydaJwk; - successRedirectUrl: string; - failureRedirectUrl: string; scope: string; acrValues: string; claimsLocales: string; @@ -36,10 +36,7 @@ const REQUIRED_VARS = [ 'FAYDA_AUTHORIZATION_ENDPOINT', 'FAYDA_TOKEN_ENDPOINT', 'FAYDA_USERINFO_ENDPOINT', - 'FAYDA_REDIRECT_URI', 'FAYDA_PRIVATE_KEY_BASE64', - 'FAYDA_SUCCESS_REDIRECT_URL', - 'FAYDA_FAILURE_REDIRECT_URL', ] as const; function decodePrivateJwk(base64: string): FaydaJwk { @@ -73,7 +70,7 @@ export default registerAs('fayda', (): FaydaConfig => { 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, @@ -81,10 +78,8 @@ export default registerAs('fayda', (): FaydaConfig => { authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT ?? '', tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT ?? '', userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '', - redirectUri: process.env.FAYDA_REDIRECT_URI ?? '', + redirectUri, privateJwk: { kty: 'RSA', n: '', e: '', d: '' }, - successRedirectUrl: process.env.FAYDA_SUCCESS_REDIRECT_URL ?? '', - failureRedirectUrl: process.env.FAYDA_FAILURE_REDIRECT_URL ?? '', scope, acrValues, claimsLocales, @@ -98,6 +93,11 @@ export default registerAs('fayda', (): FaydaConfig => { `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'); } @@ -108,10 +108,8 @@ export default registerAs('fayda', (): FaydaConfig => { authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT!, tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT!, userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!, - redirectUri: process.env.FAYDA_REDIRECT_URI!, + redirectUri, privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!), - successRedirectUrl: process.env.FAYDA_SUCCESS_REDIRECT_URL!, - failureRedirectUrl: process.env.FAYDA_FAILURE_REDIRECT_URL!, scope, acrValues, claimsLocales, 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 496010b30..f1eb25e8e 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts @@ -7,7 +7,6 @@ import { Post, Query, Req, - Res, UseGuards, } from '@nestjs/common'; import { @@ -19,6 +18,7 @@ import { import { JwtGuard } from '../../common/jwt.guard'; import { OptionalJwtGuard } from './optional-jwt.guard'; import { + CompleteVerificationResultDto, StartVerificationDto, VerifaydaCallbackDto, VerificationStatusDto, @@ -33,17 +33,14 @@ interface AuthedUser { passengerId?: string; } -/** Minimal slices of the Express req/res we actually touch (avoids a hard - * dependency on `@types/express`, which isn't resolved in this package). */ +/** 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; } -interface RedirectableResponse { - redirect(url: string): void; -} @ApiTags('Fayda Verification') @Controller('fayda/verification') @@ -77,6 +74,7 @@ export class VerifaydaController { ): 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, @@ -84,19 +82,16 @@ export class VerifaydaController { return { authorizationUrl }; } - @Get('callback') + @Get('complete') @ApiOperation({ - summary: 'eSignet redirect callback (browser lands here)', - description: `Fayda/eSignet redirects the user's browser here with \`?code&state\` (success) or \`?error&error_description\` (failure). - -This endpoint is **not** authenticated — Fayda sends no bearer token. It exchanges the code for tokens, fetches the verified claims, records the result, and **302-redirects** the browser to the configured success or failure frontend URL (failures carry a \`?reason=\` the frontend can switch on).`, + 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\``, }) - async callback( - @Query() query: VerifaydaCallbackDto, - @Res() res: RedirectableResponse, - ): Promise { - const redirectUrl = await this.service.handleCallback(query); - res.redirect(redirectUrl); + @ApiOkResponse({ type: CompleteVerificationResultDto }) + async complete( + @Query() dto: VerifaydaCallbackDto, + ): Promise { + return this.service.completeVerification(dto); } @Get('status') 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 388c8b438..005a3e517 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts @@ -26,6 +26,42 @@ export class StartVerificationDto { @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 { 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 e54b94726..d850b1dbf 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts @@ -2,9 +2,12 @@ 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 index 55c670946..e4b8cb790 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 @@ -1,15 +1,10 @@ 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'; -type AnyFn = (...args: any[]) => any; - -function mockFn(impl?: T): jest.Mock { - return impl ? jest.fn(impl) : jest.fn(); -} - function buildPrismaMock() { return { faydaVerificationSession: { @@ -24,12 +19,23 @@ function buildPrismaMock() { 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, @@ -37,10 +43,8 @@ function buildConfig(overrides?: Partial): FaydaConfig { 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', + redirectUri: 'http://localhost:4000/fayda/verification/complete', 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', @@ -59,8 +63,9 @@ function buildConfigService(faydaConfig: FaydaConfig): jest.Mocked; } -describe('VerifaydaService (OIDC)', () => { +describe('VerifaydaService (OIDC, client-callback)', () => { let prisma: ReturnType; + let jwt: jest.Mocked; let service: VerifaydaService; let realPrivateJwk: JWK; @@ -72,10 +77,12 @@ describe('VerifaydaService (OIDC)', () => { 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(); }); @@ -94,29 +101,42 @@ describe('VerifaydaService (OIDC)', () => { 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(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.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('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 disabledCfg = buildConfig({ enabled: false }); const disabledService = new VerifaydaService( - buildConfigService(disabledCfg), + buildConfigService(buildConfig({ enabled: false })), prisma as unknown as PrismaService, + jwt, ); await expect( disabledService.startVerification({ purpose: 'PURCHASE' }), @@ -124,13 +144,14 @@ describe('VerifaydaService (OIDC)', () => { }); }); - describe('handleCallback', () => { + 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, @@ -142,6 +163,69 @@ describe('VerifaydaService (OIDC)', () => { }; } + 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, @@ -154,52 +238,7 @@ describe('VerifaydaService (OIDC)', () => { (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 () => { + it('stamps the booking seats and returns { verified, fullName }', async () => { prisma.faydaVerificationSession.findUnique.mockResolvedValue( pendingSession({ bookingId: 'booking-1' }), ); @@ -207,45 +246,32 @@ describe('VerifaydaService (OIDC)', () => { prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 }); mockFetchSequence( - { - json: async () => ({ access_token: 'tok', token_type: 'Bearer' }), - }, + { 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', - }), + JSON.stringify({ sub: 'fayda-sub-1', name: 'Test User' }), }, ); - const url = await service.handleCallback({ + const result = await service.completeVerification({ code: 'authcode', state: 'state-abc', }); - expect(url).toBe('https://passenger.edr.test/verify/success'); + 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', - faydaVerifiedName: 'Test User', - }), + data: expect.objectContaining({ faydaSub: 'fayda-sub-1' }), }); - expect(prisma.faydaVerificationSession.update).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - status: 'COMPLETED', - codeVerifier: '', - }), - }), - ); }); - it('saves to User when saveToAccount=true and no conflict', async () => { + it('saves to the User account when saveToAccount=true and no conflict', async () => { prisma.faydaVerificationSession.findUnique.mockResolvedValue( pendingSession({ userId: 'user-1', saveToAccount: true }), ); @@ -262,26 +288,24 @@ describe('VerifaydaService (OIDC)', () => { }, ); - const url = await service.handleCallback({ + const result = await service.completeVerification({ code: 'authcode', state: 'state-abc', }); - expect(url).toBe('https://passenger.edr.test/verify/success'); + expect(result.verified).toBe(true); expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: 'user-1' }, - data: expect.objectContaining({ - faydaVerified: true, - faydaSub: 'fayda-sub-2', - }), + data: expect.objectContaining({ faydaVerified: true, faydaSub: 'fayda-sub-2' }), }); }); - it('rejects with identity_conflict when faydaSub is on another user', async () => { + 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' }) }, @@ -292,33 +316,29 @@ describe('VerifaydaService (OIDC)', () => { }, ); - const url = await service.handleCallback({ - code: 'authcode', - state: 'state-abc', - }); - expect(url).toContain('reason=identity_conflict'); + await expect( + service.completeVerification({ code: 'authcode', state: 'state-abc' }), + ).rejects.toMatchObject({ status: 409 }); expect(prisma.user.update).not.toHaveBeenCalled(); }); - it('reports token_exchange_failed when token endpoint returns 4xx', async () => { + 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"}', }); - const url = await service.handleCallback({ - code: 'authcode', - state: 'state-abc', - }); - expect(url).toContain('reason=token_exchange_failed'); + await expect( + service.completeVerification({ code: 'authcode', state: 'state-abc' }), + ).rejects.toMatchObject({ status: 502 }); }); - it('reports userinfo_failed when userinfo response is unsupported', async () => { + 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' }) }, { @@ -327,11 +347,9 @@ describe('VerifaydaService (OIDC)', () => { }, ); - const url = await service.handleCallback({ - code: 'authcode', - state: 'state-abc', - }); - expect(url).toContain('reason=userinfo_failed'); + 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 () => { @@ -354,10 +372,176 @@ describe('VerifaydaService (OIDC)', () => { }, ); - await service.handleCallback({ code: 'c', state: 'state-abc' }); + 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', + ); + }); + }); - const seatCall = prisma.bookingSeat.updateMany.mock.calls[0][0]; - expect(seatCall.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(); }); }); 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 55227fa6d..7a929c3c5 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts @@ -3,11 +3,15 @@ import { Injectable, Logger, ServiceUnavailableException, + UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +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 } from '../../config/fayda.config'; +import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config'; import { generateCodeChallenge, generateCodeVerifier, @@ -43,11 +47,32 @@ export interface VerifaydaVerificationResult { 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); @@ -63,6 +88,7 @@ export class VerifaydaService { constructor( private readonly config: ConfigService, private readonly prisma: PrismaService, + private readonly jwt: JwtService, ) { const fayda = this.config.get('fayda'); if (!fayda) { @@ -107,6 +133,7 @@ export class VerifaydaService { state, codeVerifier, purpose: input.purpose, + platform: input.platform ?? 'WEB', saveToAccount: input.saveToAccount ?? false, userId: input.userId ?? null, bookingId: input.bookingId ?? null, @@ -115,13 +142,16 @@ export class VerifaydaService { }); this.logger.log( - `Fayda verification started: purpose=${input.purpose} userId=${input.userId ?? 'none'} bookingId=${input.bookingId ?? 'none'}`, + `Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'} bookingId=${input.bookingId ?? 'none'}`, ); return this.buildAuthorizationUrl({ state, codeChallenge }); } - async handleCallback(query: VerifaydaCallbackDto): Promise { + + async completeVerification( + query: VerifaydaCallbackDto, + ): Promise { if (query.error) { this.logger.warn(`Fayda callback returned error: ${query.error}`); if (query.state) { @@ -131,32 +161,36 @@ export class VerifaydaService { query.error_description, ); } - return this.buildFailureUrl(query.error); + throw new BadRequestException({ + code: 'FAYDA_AUTH_ERROR', + message: query.error, + description: query.error_description, + }); } if (!query.code || !query.state) { - this.logger.warn('Fayda callback missing code or state'); - return this.buildFailureUrl('missing_parameters'); + 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) { - 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 || 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'); - this.logger.warn('Fayda callback for expired session'); - return this.buildFailureUrl('session_expired'); + throw new BadRequestException({ + code: 'FAYDA_SESSION_EXPIRED', + message: 'Verification session has expired; start again', + }); } try { @@ -171,25 +205,29 @@ export class VerifaydaService { 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 { - await this.handleLoginSuccess(session, normalized); + 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: '', - }, + data: { status: 'COMPLETED', completedAt: new Date(), codeVerifier: '' }, }); this.logger.log( - `Fayda verification completed: purpose=${session.purpose}`, + `Fayda verification completed: purpose=${session.purpose} platform=${session.platform}`, ); - return this.buildSuccessUrl(); + return result; } catch (err) { const reason = this.classifyFailureReason(err); this.logger.error( @@ -200,10 +238,45 @@ export class VerifaydaService { reason, (err as Error).message, ); - return this.buildFailureUrl(reason); + 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 }, @@ -383,17 +456,106 @@ export class VerifaydaService { } } + /** + * 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( - _session: { id: string }, - _normalized: NormalizedFaydaUserInfo, - ): Promise { - // 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', + 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( @@ -413,16 +575,6 @@ export class VerifaydaService { }); } - 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';