chore( iam ): remove dead auth DTOs and @nestjs/jwt; migrate fayda/passenger to iamUserId

This commit is contained in:
Abubeker Yasin
2026-06-08 09:02:54 +03:00
parent b509fccf93
commit ac153fb0d0
5 changed files with 58 additions and 262 deletions

View File

@@ -29,7 +29,6 @@
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.19",
"@nestjs/event-emitter": "^2.0.4",
"@nestjs/jwt": "^10.2.0",
"@nestjs/platform-express": "^11.1.19",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^7.4.0",

View File

@@ -2,160 +2,50 @@ import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RegisterDto {
@ApiProperty({
description: 'Full name of the passenger',
example: 'Kelemu Ketsela',
minLength: 2,
maxLength: 100
})
@ApiProperty({ example: 'Kelemu Ketsela' })
@IsString()
fullName: string;
@ApiProperty({
description: 'Email address (must be unique)',
example: 'kelemu@email.com',
format: 'email'
})
@ApiProperty({ example: 'kelemu@email.com' })
@IsEmail()
email: string;
@ApiProperty({
description: 'Phone number with country code',
example: '+251912345678',
pattern: '^\\+[1-9]\\d{1,14}$'
})
@ApiProperty({ example: '+251912345678' })
@IsString()
phone: string;
@ApiProperty({
description: 'Password (minimum 8 characters)',
example: 'SecurePass123',
minLength: 8,
format: 'password'
})
@ApiProperty({ example: 'SecurePass123', minLength: 8, format: 'password' })
@IsString()
@MinLength(8)
password: string;
@ApiPropertyOptional({
description: 'Confirm password (must match password)',
example: 'SecurePass123',
format: 'password'
})
@ApiPropertyOptional({ example: 'SecurePass123', format: 'password' })
@IsOptional()
@IsString()
confirmPassword?: string;
@ApiPropertyOptional({
description: 'Nationality of the passenger',
example: 'Ethiopian'
})
@ApiPropertyOptional({ example: 'Ethiopian' })
@IsOptional()
@IsString()
nationality?: string;
@ApiPropertyOptional({
description: 'National ID number',
example: 'ET123456789'
})
@ApiPropertyOptional({ example: 'ET123456789' })
@IsOptional()
@IsString()
nationalId?: string;
@ApiPropertyOptional({
description: 'Passport number for international travelers',
example: 'P1234567'
})
@ApiPropertyOptional({ example: 'P1234567' })
@IsOptional()
@IsString()
passportNumber?: string;
}
export class LoginDto {
@ApiProperty({
description: 'Registered email address',
example: 'kelemu@email.com',
format: 'email'
})
@ApiProperty({ example: 'kelemu@email.com' })
@IsEmail()
email: string;
@ApiProperty({
description: 'Account password',
example: 'password123',
format: 'password'
})
@ApiProperty({ example: 'password123', format: 'password' })
@IsString()
password: string;
}
export class RequestOtpDto {
@ApiProperty({
description: 'Email address to send OTP',
example: 'kelemu@email.com'
})
@IsEmail()
email: string;
@ApiProperty({
description: 'Purpose of OTP (REGISTRATION, PASSWORD_RESET, VERIFICATION)',
example: 'REGISTRATION',
enum: ['REGISTRATION', 'PASSWORD_RESET', 'VERIFICATION']
})
@IsString()
purpose: string;
}
export class VerifyOtpDto {
@ApiProperty({
description: 'Email address',
example: 'kelemu@email.com'
})
@IsEmail()
email: string;
@ApiProperty({
description: '6-digit OTP code',
example: '123456',
minLength: 6,
maxLength: 6
})
@IsString()
code: string;
@ApiProperty({
description: 'Purpose of OTP verification',
example: 'REGISTRATION',
enum: ['REGISTRATION', 'PASSWORD_RESET', 'VERIFICATION']
})
@IsString()
purpose: string;
}
export class RequestPasswordResetDto {
@ApiProperty({
description: 'Email address of the account',
example: 'kelemu@email.com'
})
@IsEmail()
email: string;
}
export class ResetPasswordDto {
@ApiProperty({
description: 'Password reset token received via email',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
})
@IsString()
token: string;
@ApiProperty({
description: 'New password (minimum 8 characters)',
example: 'NewSecurePass123',
minLength: 8,
format: 'password'
})
@IsString()
@MinLength(8)
newPassword: string;
}

View File

@@ -232,10 +232,12 @@ export class PassengersService {
// If logged in, update user profile and link passenger
if (isLoggedIn) {
const user = await this.prisma.user.findUnique({
where: { id: dto.userId },
include: { passenger: true },
// dto.userId is the IAM user UUID — resolve via Passenger.iamUserId
const linkedPassenger = await this.prisma.passenger.findUnique({
where: { iamUserId: dto.userId },
include: { user: true },
});
const user = linkedPassenger?.user ?? null;
if (!user) {
throw new BadRequestException('User not found');
@@ -244,7 +246,7 @@ export class PassengersService {
// Update user record if not already verified
if (!user.faydaVerified && verifiedData) {
await this.prisma.user.update({
where: { id: dto.userId },
where: { id: user.id },
data: {
fullName: finalData.passengerName,
nationality: finalData.nationality,
@@ -257,7 +259,7 @@ export class PassengersService {
}
return {
id: user.passenger?.id || user.id,
id: linkedPassenger?.id || user.id,
passengerName: finalData.passengerName,
dateOfBirth: finalData.dateOfBirth,
nationality: finalData.nationality,

View File

@@ -7,8 +7,6 @@ import {
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
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 {
@@ -142,7 +140,8 @@ export class VerifaydaService {
purpose: input.purpose,
platform: input.platform ?? 'WEB',
saveToAccount: input.saveToAccount ?? false,
userId: input.userId ?? null,
iamUserId: input.userId ?? null,
userId: null,
bookingId: input.bookingId ?? null,
expiresAt,
},
@@ -283,16 +282,15 @@ export class VerifaydaService {
});
}
async getVerificationStatus(userId: string): Promise<VerificationStatusDto> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { faydaVerified: true, faydaVerifiedAt: true, fullName: true },
async getVerificationStatus(iamUserId: string): Promise<VerificationStatusDto> {
const passenger = await this.prisma.passenger.findUnique({
where: { iamUserId },
include: { user: { select: { faydaVerified: true, faydaVerifiedAt: true, fullName: true } } },
});
return {
verified: user?.faydaVerified ?? false,
verifiedAt: user?.faydaVerifiedAt ?? undefined,
fullName: user?.fullName ?? undefined,
verified: passenger?.user?.faydaVerified ?? false,
verifiedAt: passenger?.user?.faydaVerifiedAt ?? undefined,
fullName: passenger?.user?.fullName ?? undefined,
};
}
@@ -422,6 +420,7 @@ export class VerifaydaService {
private async handlePurchaseSuccess(
session: {
id: string;
iamUserId: string | null;
userId: string | null;
bookingId: string | null;
saveToAccount: boolean;
@@ -439,129 +438,38 @@ export class VerifaydaService {
});
}
if (session.userId && session.saveToAccount) {
const iamUserId = session.iamUserId;
if (iamUserId && session.saveToAccount) {
const passenger = await this.prisma.passenger.findUnique({
where: { iamUserId },
select: { userId: true },
});
const localUserId = passenger?.userId;
if (!localUserId) return;
const conflict = await this.prisma.user.findFirst({
where: {
faydaSub: normalized.sub,
NOT: { id: session.userId },
},
where: { faydaSub: normalized.sub, NOT: { id: localUserId } },
select: { id: true },
});
if (conflict) {
throw new FaydaIdentityConflictException();
}
if (conflict) throw new FaydaIdentityConflictException();
await this.prisma.user.update({
where: { id: session.userId },
data: {
faydaVerified: true,
faydaVerifiedAt: new Date(),
faydaSub: normalized.sub,
},
where: { id: localUserId },
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.
*/
// LOGIN via Fayda is now handled entirely by the IAM package's own OIDC flow.
// This method is kept as a stub so completeVerification() still compiles;
// it throws immediately without touching the database.
private async handleLoginSuccess(
normalized: NormalizedFaydaUserInfo,
_normalized: NormalizedFaydaUserInfo,
): Promise<{ userId: string }> {
let userId: string;
const bySub = await this.prisma.user.findUnique({
where: { faydaSub: normalized.sub },
select: { id: true },
throw new UnauthorizedException({
code: 'FAYDA_LOGIN_MIGRATED_TO_IAM',
message: 'Fayda login tokens are issued by the IAM package at /v1/auth/fayda endpoints.',
});
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<string> {
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(

3
pnpm-lock.yaml generated
View File

@@ -150,9 +150,6 @@ importers:
'@nestjs/event-emitter':
specifier: ^2.0.4
version: 2.1.1(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)
'@nestjs/jwt':
specifier: ^10.2.0
version: 10.2.0(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
'@nestjs/platform-express':
specifier: ^11.1.19
version: 11.1.23(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)