diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index e1626bef5..84a17f4ef 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -36,6 +36,7 @@ import { AgentsModule } from './modules/agents/agents.module'; import { ReportsModule } from './modules/reports/reports.module'; import { FraudModule } from './modules/fraud/fraud.module'; import { SeatClassesModule } from './modules/seat-classes/seat-classes.module'; +import { VerifaydaModule } from './modules/verifayda/verifayda.module'; @Module({ imports: [ @@ -79,6 +80,7 @@ import { SeatClassesModule } from './modules/seat-classes/seat-classes.module'; ReportsModule, FraudModule, SeatClassesModule, + VerifaydaModule, ], }) export class AppModule implements NestModule { 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/verifayda.controller.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts new file mode 100644 index 000000000..496010b30 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts @@ -0,0 +1,116 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Query, + Req, + Res, + 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 { + 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/res we actually 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') +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', + userId: req.user?.userId, + bookingId: dto.bookingId, + saveToAccount: dto.saveToAccount, + }); + return { authorizationUrl }; + } + + @Get('callback') + @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).`, + }) + async callback( + @Query() query: VerifaydaCallbackDto, + @Res() res: RedirectableResponse, + ): Promise { + const redirectUrl = await this.service.handleCallback(query); + res.redirect(redirectUrl); + } + + @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.module.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts index c14ba3f1f..e54b94726 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,11 @@ import { Module } from '@nestjs/common'; +import { VerifaydaController } from './verifayda.controller'; import { VerifaydaService } from './verifayda.service'; import { PrismaModule } from '../../common/prisma.module'; @Module({ imports: [PrismaModule], + controllers: [VerifaydaController], providers: [VerifaydaService], exports: [VerifaydaService], })