mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
@@ -66,7 +66,9 @@ function decodePrivateJwk(base64: string): FaydaJwk {
|
|||||||
|
|
||||||
export default registerAs('fayda', (): FaydaConfig => {
|
export default registerAs('fayda', (): FaydaConfig => {
|
||||||
const enabled = (process.env.FAYDA_ENABLED ?? 'false').toLowerCase() === 'true';
|
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 acrValues = process.env.FAYDA_ACR_VALUES ?? 'mosip:idp:acr:generated-code';
|
||||||
const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am';
|
const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am';
|
||||||
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
|
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
|
||||||
|
|||||||
@@ -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("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("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("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("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("Config", "System settings, feature flags, and configuration management")
|
||||||
.addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion")
|
.addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion")
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nes
|
|||||||
import { Throttle, SkipThrottle } from '@nestjs/throttler';
|
import { Throttle, SkipThrottle } from '@nestjs/throttler';
|
||||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||||
import { PassengerAuthService } from './passenger-auth.service';
|
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';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
|
|
||||||
@ApiTags('Auth')
|
@ApiTags('Passenger Auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@Throttle({ auth: { limit: 5, ttl: 60_000 } })
|
@Throttle({ auth: { limit: 5, ttl: 60_000 } })
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
@@ -118,4 +118,24 @@ export class AuthController {
|
|||||||
resetPassword(@Param('id') id: string, @Body() body: { tempPassword: string }) {
|
resetPassword(@Param('id') id: string, @Body() body: { tempPassword: string }) {
|
||||||
return this.passengerAuthService.resetUserPassword(id, body.tempPassword);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { IsEmail, IsString, MinLength, ValidateNested } from 'class-validator';
|
import { IsEmail, IsString, MinLength, ValidateNested } from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class NameDto {
|
export class NameDto {
|
||||||
@ApiProperty({ example: 'ቀለሙ ቀጸላ' })
|
@ApiProperty({ example: 'ቀለሙ ቀጸላ' })
|
||||||
@@ -49,3 +49,19 @@ export class LoginDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
password: string;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
|
Logger,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ModuleRef, ContextIdFactory } from '@nestjs/core';
|
import { ModuleRef, ContextIdFactory } from '@nestjs/core';
|
||||||
@@ -10,6 +11,7 @@ import { DataSource } from 'typeorm';
|
|||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { AuthService as IamAuthService } from '@tria-plc/iamapi-common/module/auth/services/auth.service';
|
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 { 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 { PrismaService } from '../../common/prisma.service';
|
||||||
import { RegisterDto, LoginDto } from './auth.dto';
|
import { RegisterDto, LoginDto } from './auth.dto';
|
||||||
|
|
||||||
@@ -19,10 +21,13 @@ type IamUserRow = {
|
|||||||
name: { en: string; am: string } | null;
|
name: { en: string; am: string } | null;
|
||||||
phone_number: string | null;
|
phone_number: string | null;
|
||||||
metadata: Record<string, any> | null;
|
metadata: Record<string, any> | null;
|
||||||
|
verified_by: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PassengerAuthService {
|
export class PassengerAuthService {
|
||||||
|
private readonly logger = new Logger(PassengerAuthService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
@InjectDataSource() private readonly dataSource: DataSource,
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
@@ -165,7 +170,7 @@ export class PassengerAuthService {
|
|||||||
include: { loyalty: true, wallet: true },
|
include: { loyalty: true, wallet: true },
|
||||||
}),
|
}),
|
||||||
this.dataSource.query<IamUserRow[]>(
|
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],
|
[iamUserId],
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -178,7 +183,7 @@ export class PassengerAuthService {
|
|||||||
email: iam?.email ?? null,
|
email: iam?.email ?? null,
|
||||||
phone: iam?.phone_number ?? null,
|
phone: iam?.phone_number ?? null,
|
||||||
fullName: iam?.name?.en ?? iam?.name?.am ?? null,
|
fullName: iam?.name?.en ?? iam?.name?.am ?? null,
|
||||||
faydaVerified: iam?.metadata?.faydaVerified ?? false,
|
faydaVerified: iam?.verified_by === 'fayda',
|
||||||
createdAt: passenger.createdAt,
|
createdAt: passenger.createdAt,
|
||||||
passenger: {
|
passenger: {
|
||||||
id: passenger.id,
|
id: passenger.id,
|
||||||
@@ -396,6 +401,121 @@ export class PassengerAuthService {
|
|||||||
return { success: true, message: 'Password reset successfully' };
|
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> {
|
private async compensateIamSignup(email: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const rows = await this.dataSource.query<{ id: string }[]>(
|
const rows = await this.dataSource.query<{ id: string }[]>(
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export class VerifaydaController {
|
|||||||
purpose: dto.purpose ?? 'VERIFY',
|
purpose: dto.purpose ?? 'VERIFY',
|
||||||
platform: dto.platform ?? 'WEB',
|
platform: dto.platform ?? 'WEB',
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
|
wantsPasswordSetup: dto.wantsPasswordSetup ?? false,
|
||||||
});
|
});
|
||||||
return { authorizationUrl };
|
return { authorizationUrl };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ export class StartVerificationDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['WEB', 'MOBILE'])
|
@IsIn(['WEB', 'MOBILE'])
|
||||||
platform?: '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 {
|
export class CompleteVerificationResultDto {
|
||||||
@@ -29,9 +40,12 @@ export class CompleteVerificationResultDto {
|
|||||||
|
|
||||||
@ApiProperty() verified: boolean;
|
@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;
|
token?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
refreshToken?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Authenticated user summary (LOGIN flow only; same shape as /auth/login).',
|
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.' })
|
@ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' })
|
||||||
userDataSaved?: boolean;
|
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 {
|
export class VerifaydaCallbackDto {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
import { generateToken, generateRefreshToken } from '@tria-plc/api-common/utils/token';
|
||||||
import axios, { AxiosInstance } from 'axios';
|
import axios, { AxiosInstance } from 'axios';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config';
|
import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config';
|
||||||
@@ -47,6 +48,7 @@ export interface StartVerificationInput {
|
|||||||
purpose: VerifaydaPurpose;
|
purpose: VerifaydaPurpose;
|
||||||
platform?: FaydaPlatform;
|
platform?: FaydaPlatform;
|
||||||
userId?: string; // iamUserId of the authenticated user, if any
|
userId?: string; // iamUserId of the authenticated user, if any
|
||||||
|
wantsPasswordSetup?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FaydaUserSummary {
|
export interface FaydaUserSummary {
|
||||||
@@ -66,6 +68,10 @@ export interface CompleteVerificationResult {
|
|||||||
purpose: VerifaydaPurpose;
|
purpose: VerifaydaPurpose;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
token?: string;
|
token?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
requiresPassword?: boolean;
|
||||||
|
promptPasswordSetup?: boolean;
|
||||||
|
iamUserId?: string;
|
||||||
user?: FaydaUserSummary;
|
user?: FaydaUserSummary;
|
||||||
fullName?: string;
|
fullName?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
@@ -145,6 +151,7 @@ export class VerifaydaService {
|
|||||||
codeVerifier,
|
codeVerifier,
|
||||||
purpose: input.purpose,
|
purpose: input.purpose,
|
||||||
platform: input.platform ?? 'WEB',
|
platform: input.platform ?? 'WEB',
|
||||||
|
saveToAccount: input.wantsPasswordSetup ?? false,
|
||||||
iamUserId: input.userId ?? null,
|
iamUserId: input.userId ?? null,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
},
|
},
|
||||||
@@ -220,8 +227,18 @@ export class VerifaydaService {
|
|||||||
const login = await this.issueLoginToken(userId);
|
const login = await this.issueLoginToken(userId);
|
||||||
result = { purpose: 'LOGIN', verified: true, ...login };
|
result = { purpose: 'LOGIN', verified: true, ...login };
|
||||||
} else {
|
} else {
|
||||||
// VERIFY — prove identity, save to IAM, return verified attributes.
|
// VERIFY — prove identity, save to IAM, return verified attributes + short-lived token.
|
||||||
const { userDataSaved } = await this.upsertIamUser(normalized);
|
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 = {
|
result = {
|
||||||
purpose: 'VERIFY',
|
purpose: 'VERIFY',
|
||||||
verified: true,
|
verified: true,
|
||||||
@@ -231,6 +248,11 @@ export class VerifaydaService {
|
|||||||
birthdate: normalized.birthdate,
|
birthdate: normalized.birthdate,
|
||||||
gender: normalized.gender,
|
gender: normalized.gender,
|
||||||
userDataSaved,
|
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,
|
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 = {
|
const claims = {
|
||||||
userinfo: {
|
userinfo: {
|
||||||
name: { essential: true },
|
name: { essential: true },
|
||||||
phone_number: { essential: true },
|
phone_number: { essential: true },
|
||||||
email: { essential: false },
|
email: { essential: true },
|
||||||
birthdate: { essential: true },
|
birthdate: { essential: true },
|
||||||
gender: { essential: false },
|
gender: { essential: true },
|
||||||
picture: { essential: false },
|
address: { essential: true },
|
||||||
|
nationality: { essential: true },
|
||||||
|
picture: { essential: true },
|
||||||
},
|
},
|
||||||
id_token: {},
|
id_token: {},
|
||||||
};
|
};
|
||||||
@@ -447,12 +474,16 @@ export class VerifaydaService {
|
|||||||
phoneNumber: normalized.rawPhoneNumber ?? '',
|
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 }[]>(
|
const bySub = await this.dataSource.query<{ id: string }[]>(
|
||||||
`SELECT id FROM iam.users WHERE metadata->>'sub' = $1 LIMIT 1`,
|
`SELECT id FROM iam.users WHERE metadata->>'sub' = $1 LIMIT 1`,
|
||||||
[normalized.sub],
|
[normalized.sub],
|
||||||
);
|
);
|
||||||
if (bySub.length > 0) {
|
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 };
|
return { iamUserId: bySub[0].id, userDataSaved: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,7 +528,7 @@ export class VerifaydaService {
|
|||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
gen_random_uuid(), $1::jsonb, $2, $3, $4, $5::jsonb,
|
gen_random_uuid(), $1::jsonb, $2, $3, $4, $5::jsonb,
|
||||||
'individual', 'accepted', true, false,
|
'individual', 'submitted', true, false,
|
||||||
false, 'fayda',
|
false, 'fayda',
|
||||||
NOW(), NOW()
|
NOW(), NOW()
|
||||||
) RETURNING id`,
|
) 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(
|
private async markSessionFailed(
|
||||||
state: string,
|
state: string,
|
||||||
errorCode: string,
|
errorCode: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user