Merge pull request #304 from Tria-plc/alpha

Alpha
This commit is contained in:
Abubeker Yasin
2026-06-26 14:34:12 +03:00
committed by GitHub
8 changed files with 286 additions and 15 deletions

View File

@@ -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);

View File

@@ -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")

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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<string, any> | 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<IamUserRow[]>(
`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<void> {
try {
const rows = await this.dataSource.query<{ id: string }[]>(

View File

@@ -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 };
}

View File

@@ -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 {

View File

@@ -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,