diff --git a/apps/edr-passenger-api/src/config/fayda.config.ts b/apps/edr-passenger-api/src/config/fayda.config.ts index e0bce45c6..d8c4b1868 100644 --- a/apps/edr-passenger-api/src/config/fayda.config.ts +++ b/apps/edr-passenger-api/src/config/fayda.config.ts @@ -66,7 +66,9 @@ function decodePrivateJwk(base64: string): FaydaJwk { export default registerAs('fayda', (): FaydaConfig => { const enabled = (process.env.FAYDA_ENABLED ?? 'false').toLowerCase() === 'true'; - const scope = process.env.FAYDA_SCOPE ?? 'openid profile email'; + // `profile` covers name/birthdate/gender/picture; `email`, `phone`, `address` + // are needed so the matching essential claims aren't rejected as out-of-scope. + const scope = process.env.FAYDA_SCOPE ?? 'openid profile email phone address'; 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); diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index 86d33debb..680e67866 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -314,7 +314,7 @@ Payment providers send notifications to: .addTag("Excess Baggage", "IAM-protected agent/supervisor endpoints to log excess baggage charges, waive fees, resend payment links, and manage allowance rules per seat class. Public token-based endpoints let passengers self-pay outstanding charges.") .addTag("Packages", "Bundled travel packages with tiered pricing. Public endpoints for browsing and booking; JWT-authenticated endpoints for purchase history; IAM-protected endpoints for admin CRUD and tier management.") .addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails") - .addTag("Auth", "Passenger registration, login, OTP, password reset, and profile management") + .addTag("Passenger Auth", "Passenger registration, login, OTP, password reset, Fayda password setup, and profile management") .addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout. Supports ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT booking types. returnLegStatus filter for round-trip no-show management") .addTag("Config", "System settings, feature flags, and configuration management") .addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion") diff --git a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts index 80e67d3de..d53ef40d7 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts @@ -3,10 +3,10 @@ import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nes import { Throttle, SkipThrottle } from '@nestjs/throttler'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { PassengerAuthService } from './passenger-auth.service'; -import { RegisterDto, LoginDto } from './auth.dto'; +import { RegisterDto, LoginDto, FaydaRequestPasswordSetupDto, FaydaVerifyAndLoginDto } from './auth.dto'; import { JwtGuard } from '../../common/jwt.guard'; -@ApiTags('Auth') +@ApiTags('Passenger Auth') @Controller('auth') @Throttle({ auth: { limit: 5, ttl: 60_000 } }) export class AuthController { @@ -118,4 +118,24 @@ export class AuthController { resetPassword(@Param('id') id: string, @Body() body: { tempPassword: string }) { return this.passengerAuthService.resetUserPassword(id, body.tempPassword); } + + @Post('fayda/request-password-setup') + @IsPublic() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Send OTP to phone for Fayda-verified account password setup' }) + @ApiResponse({ status: 200, description: 'OTP sent to registered phone number' }) + @ApiBody({ type: FaydaRequestPasswordSetupDto }) + requestFaydaPasswordSetup(@Body() dto: FaydaRequestPasswordSetupDto, @Request() req: any) { + return this.passengerAuthService.requestFaydaPasswordSetup(dto.phoneNumber, req); + } + + @Post('fayda/verify-and-login') + @IsPublic() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Verify OTP and receive session token for Fayda-verified account' }) + @ApiResponse({ status: 200, description: 'Returns token + requiresPassword flag. Use token with POST /v1/auth/set-fayda-password.' }) + @ApiBody({ type: FaydaVerifyAndLoginDto }) + verifyFaydaAndLogin(@Body() dto: FaydaVerifyAndLoginDto) { + return this.passengerAuthService.verifyFaydaAndLogin(dto.phoneNumber, dto.otp); + } } diff --git a/apps/edr-passenger-api/src/modules/auth/auth.dto.ts b/apps/edr-passenger-api/src/modules/auth/auth.dto.ts index e12c8cd06..6f43e7212 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.dto.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.dto.ts @@ -1,6 +1,6 @@ import { IsEmail, IsString, MinLength, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class NameDto { @ApiProperty({ example: 'ቀለሙ ቀጸላ' }) @@ -49,3 +49,19 @@ export class LoginDto { @IsString() password: string; } + +export class FaydaRequestPasswordSetupDto { + @ApiProperty({ example: '+251911234567', description: 'Phone number of the Fayda-verified account' }) + @IsString() + phoneNumber: string; +} + +export class FaydaVerifyAndLoginDto { + @ApiProperty({ example: '+251911234567' }) + @IsString() + phoneNumber: string; + + @ApiProperty({ example: '123456', description: '6-digit OTP received via SMS' }) + @IsString() + otp: string; +} diff --git a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts index 4192091dd..cd583517c 100644 --- a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts +++ b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts @@ -2,6 +2,7 @@ import { Injectable, ConflictException, InternalServerErrorException, + Logger, UnauthorizedException, } from '@nestjs/common'; import { ModuleRef, ContextIdFactory } from '@nestjs/core'; @@ -10,6 +11,7 @@ import { DataSource } from 'typeorm'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { AuthService as IamAuthService } from '@tria-plc/iamapi-common/module/auth/services/auth.service'; import { EUserType } from '@tria-plc/api-common/utils/enums/user.enum'; +import { EOtpType } from '@tria-plc/iamapi-common/enums/otp.enum'; import { PrismaService } from '../../common/prisma.service'; import { RegisterDto, LoginDto } from './auth.dto'; @@ -19,10 +21,13 @@ type IamUserRow = { name: { en: string; am: string } | null; phone_number: string | null; metadata: Record | null; + verified_by: string | null; }; @Injectable() export class PassengerAuthService { + private readonly logger = new Logger(PassengerAuthService.name); + constructor( private readonly prisma: PrismaService, @InjectDataSource() private readonly dataSource: DataSource, @@ -165,7 +170,7 @@ export class PassengerAuthService { include: { loyalty: true, wallet: true }, }), this.dataSource.query( - `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`, + `SELECT id, email, name, phone_number, metadata, verified_by FROM iam.users WHERE id = $1 LIMIT 1`, [iamUserId], ), ]); @@ -178,7 +183,7 @@ export class PassengerAuthService { email: iam?.email ?? null, phone: iam?.phone_number ?? null, fullName: iam?.name?.en ?? iam?.name?.am ?? null, - faydaVerified: iam?.metadata?.faydaVerified ?? false, + faydaVerified: iam?.verified_by === 'fayda', createdAt: passenger.createdAt, passenger: { id: passenger.id, @@ -396,6 +401,121 @@ export class PassengerAuthService { return { success: true, message: 'Password reset successfully' }; } + async requestFaydaPasswordSetup(phoneNumber: string, req: any): Promise<{ sent: boolean }> { + const phone = this.standardizePhone(phoneNumber); + + const users = await this.dataSource.query<{ id: string; email: string }[]>( + `SELECT id, email FROM iam.users WHERE phone_number = $1 AND verified_by = 'fayda' LIMIT 1`, + [phone], + ); + this.logger.log(`requestFaydaPasswordSetup: phone=${phone} found=${users.length > 0}`); + // Return success regardless to avoid phone enumeration + if (!users.length) return { sent: true }; + const u = users[0]; + + const iamAuthService = await this.resolveIamAuthService(req); + await iamAuthService.generateVerificationCode({ + email: u.email, + phoneNumber: phone, + type: EOtpType.SET_PASSWORD, + }); + + return { sent: true }; + } + + async verifyFaydaAndLogin( + phoneNumber: string, + otp: string, + ): Promise<{ token: string; refreshToken: string; requiresPassword: boolean; iamUserId: string }> { + const phone = this.standardizePhone(phoneNumber); + + const users = await this.dataSource.query<{ + id: string; + email: string; + name: { en: string; am: string } | null; + username: string; + phone_number: string | null; + has_set_password: boolean; + }[]>( + `SELECT id, email, name, username, phone_number, has_set_password + FROM iam.users WHERE phone_number = $1 AND verified_by = 'fayda' LIMIT 1`, + [phone], + ); + if (!users.length) throw new UnauthorizedException('Invalid phone number or OTP'); + const u = users[0]; + + const verifications = await this.dataSource.query<{ + id: string; verification_code: string; attempt_count: number; + }[]>( + `SELECT id, verification_code, attempt_count FROM iam.user_verifications + WHERE user_id = $1 AND otp_type = 'set-password' AND "isUsed" = false AND expires_at > NOW() + ORDER BY created_at DESC LIMIT 1`, + [u.id], + ); + if (!verifications.length) throw new UnauthorizedException('Invalid phone number or OTP'); + const v = verifications[0]; + + if (v.attempt_count >= 5) { + await this.dataSource.query( + `UPDATE iam.user_verifications SET "isUsed" = true WHERE id = $1`, [v.id], + ); + throw new UnauthorizedException('Too many attempts. Request a new code.'); + } + + await this.dataSource.query( + `UPDATE iam.user_verifications SET attempt_count = attempt_count + 1 WHERE id = $1`, [v.id], + ); + + const { verifyPassword } = await import('@tria-plc/api-common/utils/argon'); + const valid = await verifyPassword(otp, v.verification_code); + if (!valid) throw new UnauthorizedException('Invalid phone number or OTP'); + + await this.dataSource.query( + `UPDATE iam.user_verifications SET "isUsed" = true WHERE id = $1`, [v.id], + ); + + const userInfo = { + id: u.id, + email: u.email ?? '', + name: u.name ?? { en: '', am: '' }, + userType: 'individual', + status: 'accepted', + hasSetPassword: u.has_set_password, + isPhoneNumberVerified: false, + hasFinishedRegistration: false, + hasFinishedDMSOnboarding: false, + username: u.username, + phoneNumber: u.phone_number ?? '', + roles: [], + permissions: [], + employee: [], + }; + + const sessions = await this.dataSource.query<{ id: string }[]>( + `INSERT INTO iam.sessions + (id, email, device, "userInfo", expiry_time, refresh_count, status, user_id) + VALUES (gen_random_uuid(), $1, 'fayda-otp-setup', $2::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $3) + ON CONFLICT (user_id, device) DO UPDATE + SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo", + expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW() + RETURNING id`, + [u.email ?? '', JSON.stringify(userInfo), u.id], + ); + + const { generateToken, generateRefreshToken } = await import('@tria-plc/api-common/utils/token'); + const token = generateToken({ id: sessions[0].id }); + const refreshToken = generateRefreshToken({ id: sessions[0].id }); + + return { token, refreshToken, requiresPassword: !u.has_set_password, iamUserId: u.id }; + } + + private standardizePhone(phone: string): string { + const digits = phone.replace(/\D/g, ''); + if (digits.startsWith('251')) return `+${digits}`; + if (digits.startsWith('0')) return `+251${digits.slice(1)}`; + return `+${digits}`; + } + private async compensateIamSignup(email: string): Promise { try { const rows = await this.dataSource.query<{ id: string }[]>( 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 b306d2cdc..9ba4cd515 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts @@ -74,6 +74,7 @@ export class VerifaydaController { purpose: dto.purpose ?? 'VERIFY', platform: dto.platform ?? 'WEB', userId: req.user?.id, + wantsPasswordSetup: dto.wantsPasswordSetup ?? false, }); return { authorizationUrl }; } diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts index 4842b479c..8e5e575e0 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts @@ -21,6 +21,17 @@ export class StartVerificationDto { @IsOptional() @IsIn(['WEB', 'MOBILE']) platform?: 'WEB' | 'MOBILE'; + + @ApiPropertyOptional({ + type: Boolean, + default: false, + description: + 'Set to true when the user opts in to full account registration (checkbox). ' + + 'When true, the /complete response includes a short-lived token and promptPasswordSetup=true ' + + 'so the frontend can immediately prompt for a password via POST /v1/auth/set-fayda-password.', + }) + @IsOptional() + wantsPasswordSetup?: boolean; } export class CompleteVerificationResultDto { @@ -29,9 +40,12 @@ export class CompleteVerificationResultDto { @ApiProperty() verified: boolean; - @ApiPropertyOptional({ description: 'JWT (LOGIN flow only).' }) + @ApiPropertyOptional({ description: 'JWT. LOGIN: session token for the authenticated user. VERIFY: short-lived token for calling /v1/auth/set-fayda-password.' }) token?: string; + @ApiPropertyOptional() + refreshToken?: string; + @ApiPropertyOptional({ description: 'Authenticated user summary (LOGIN flow only; same shape as /auth/login).', }) @@ -62,6 +76,19 @@ export class CompleteVerificationResultDto { @ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' }) userDataSaved?: boolean; + + @ApiPropertyOptional({ description: 'IAM user ID of the verified identity (VERIFY flow).' }) + iamUserId?: string; + + @ApiPropertyOptional({ description: 'True when the IAM account has not yet set a password (VERIFY flow).' }) + requiresPassword?: boolean; + + @ApiPropertyOptional({ + description: + 'True when the user opted in to immediate password setup (wantsPasswordSetup=true at start) ' + + 'AND they have not yet set a password. Frontend should navigate to the set-password screen.', + }) + promptPasswordSetup?: boolean; } export class VerifaydaCallbackDto { 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 407046cb4..e51bf1846 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts @@ -8,6 +8,7 @@ import { import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; +import { generateToken, generateRefreshToken } from '@tria-plc/api-common/utils/token'; import axios, { AxiosInstance } from 'axios'; import { PrismaService } from '../../common/prisma.service'; import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config'; @@ -47,6 +48,7 @@ export interface StartVerificationInput { purpose: VerifaydaPurpose; platform?: FaydaPlatform; userId?: string; // iamUserId of the authenticated user, if any + wantsPasswordSetup?: boolean; } export interface FaydaUserSummary { @@ -66,6 +68,10 @@ export interface CompleteVerificationResult { purpose: VerifaydaPurpose; verified: boolean; token?: string; + refreshToken?: string; + requiresPassword?: boolean; + promptPasswordSetup?: boolean; + iamUserId?: string; user?: FaydaUserSummary; fullName?: string; email?: string; @@ -145,6 +151,7 @@ export class VerifaydaService { codeVerifier, purpose: input.purpose, platform: input.platform ?? 'WEB', + saveToAccount: input.wantsPasswordSetup ?? false, iamUserId: input.userId ?? null, expiresAt, }, @@ -220,8 +227,18 @@ export class VerifaydaService { const login = await this.issueLoginToken(userId); result = { purpose: 'LOGIN', verified: true, ...login }; } else { - // VERIFY — prove identity, save to IAM, return verified attributes. - const { userDataSaved } = await this.upsertIamUser(normalized); + // VERIFY — prove identity, save to IAM, return verified attributes + short-lived token. + const { iamUserId, userDataSaved } = await this.upsertIamUser(normalized); + + let sessionToken: { token: string; refreshToken: string; requiresPassword: boolean } | undefined; + if (iamUserId) { + try { + sessionToken = await this.createFaydaSession(iamUserId); + } catch (err) { + this.logger.warn(`Fayda session creation failed: ${(err as Error).message}`); + } + } + result = { purpose: 'VERIFY', verified: true, @@ -231,6 +248,11 @@ export class VerifaydaService { birthdate: normalized.birthdate, gender: normalized.gender, userDataSaved, + iamUserId: iamUserId ?? undefined, + token: sessionToken?.token, + refreshToken: sessionToken?.refreshToken, + requiresPassword: sessionToken?.requiresPassword, + promptPasswordSetup: session.saveToAccount && (sessionToken?.requiresPassword ?? false), }; } @@ -298,14 +320,19 @@ export class VerifaydaService { claims_locales: this.faydaConfig.claimsLocales, }); + // Every claim is marked essential so eSignet shows them locked/pre-checked + // on the consent screen — the user cannot toggle any off; they either + // consent to all of them or the whole flow is cancelled (?error=...). const claims = { userinfo: { name: { essential: true }, phone_number: { essential: true }, - email: { essential: false }, + email: { essential: true }, birthdate: { essential: true }, - gender: { essential: false }, - picture: { essential: false }, + gender: { essential: true }, + address: { essential: true }, + nationality: { essential: true }, + picture: { essential: true }, }, id_token: {}, }; @@ -447,12 +474,16 @@ export class VerifaydaService { phoneNumber: normalized.rawPhoneNumber ?? '', }; - // Step 1 — already verified with same Fayda sub + // Step 1 — already linked to this Fayda sub; ensure verified_by is set const bySub = await this.dataSource.query<{ id: string }[]>( `SELECT id FROM iam.users WHERE metadata->>'sub' = $1 LIMIT 1`, [normalized.sub], ); if (bySub.length > 0) { + await this.dataSource.query( + `UPDATE iam.users SET verified_by = 'fayda', updated_at = NOW() WHERE id = $1`, + [bySub[0].id], + ); return { iamUserId: bySub[0].id, userDataSaved: true }; } @@ -497,7 +528,7 @@ export class VerifaydaService { created_at, updated_at ) VALUES ( gen_random_uuid(), $1::jsonb, $2, $3, $4, $5::jsonb, - 'individual', 'accepted', true, false, + 'individual', 'submitted', true, false, false, 'fayda', NOW(), NOW() ) RETURNING id`, @@ -516,6 +547,60 @@ export class VerifaydaService { } } + private async createFaydaSession( + iamUserId: string, + ): Promise<{ token: string; refreshToken: string; requiresPassword: boolean }> { + const rows = await this.dataSource.query<{ + id: string; + email: string; + name: { en: string; am: string } | null; + username: string; + phone_number: string | null; + has_set_password: boolean; + status: string; + }[]>( + `SELECT id, email, name, username, phone_number, has_set_password, status + FROM iam.users WHERE id = $1 LIMIT 1`, + [iamUserId], + ); + if (!rows.length) throw new Error(`IAM user ${iamUserId} not found`); + const u = rows[0]; + + const userInfo = { + id: u.id, + email: u.email ?? '', + name: u.name ?? { en: '', am: '' }, + userType: 'individual', + status: u.status, + hasSetPassword: u.has_set_password, + isPhoneNumberVerified: false, + hasFinishedRegistration: false, + hasFinishedDMSOnboarding: false, + username: u.username, + phoneNumber: u.phone_number ?? '', + roles: [], + permissions: [], + employee: [], + }; + + const sessions = await this.dataSource.query<{ id: string }[]>( + `INSERT INTO iam.sessions + (id, email, device, "userInfo", expiry_time, refresh_count, status, user_id) + VALUES (gen_random_uuid(), $1, 'fayda-verify', $2::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $3) + ON CONFLICT (user_id, device) DO UPDATE + SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo", + expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW() + RETURNING id`, + [u.email ?? '', JSON.stringify(userInfo), iamUserId], + ); + + const sessionId = sessions[0].id; + const token = generateToken({ id: sessionId }); + const refreshToken = generateRefreshToken({ id: sessionId }); + + return { token, refreshToken, requiresPassword: !u.has_set_password }; + } + private async markSessionFailed( state: string, errorCode: string,