fix fayda

This commit is contained in:
natib21
2026-07-03 09:10:05 +00:00
parent eb5d8a7411
commit e4b0c73c63
30 changed files with 1720 additions and 652 deletions

View File

@@ -60,3 +60,21 @@ REDIS_PORT=6379
RABBITMQ_ENABLED=false
RABBITMQ_URL=amqp://localhost:5672
SMS_QUEUE=sms_queue
# ── VeriFayda 2.0 (eSignet OIDC) identity verification ──────────────────────
# Disabled by default; /fayda/verification/start returns 503 until enabled.
FAYDA_ENABLED=false
FAYDA_CLIENT_ID=
FAYDA_AUTHORIZATION_ENDPOINT=
FAYDA_TOKEN_ENDPOINT=
FAYDA_USERINFO_ENDPOINT=
# Base64-encoded RSA private JWK used for the private_key_jwt client assertion
FAYDA_PRIVATE_KEY_BASE64=
# OAuth redirect_uri for MOBILE clients (must be registered with eSignet)
FAYDA_REDIRECT_URI=
# OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset.
FAYDA_WEB_REDIRECT_URI=
FAYDA_SCOPE=openid profile email phone address
FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
FAYDA_CLAIMS_LOCALES=en am
FAYDA_SESSION_TTL_MINUTES=10

View File

@@ -62,6 +62,7 @@
"dotenv": "^17.4.2",
"dotenv-cli": "^11.0.0",
"handlebars": "^4.7.9",
"jose": "^5.10.0",
"libphonenumber-js": "^1.13.6",
"minio": "7.1.3",
"pg": "^8.13.0",

View File

@@ -12,6 +12,7 @@ import appConfig from "./config/app.config";
import databaseConfig from "./config/database.config";
import telebirrConfig from "./config/telebirr.config";
import rabbitmqConfig from "./config/rabbitmq.config";
import faydaConfig from "./config/fayda.config";
import { BookingsModule } from "./modules/bookings/bookings.module";
import { ContractsModule } from "./modules/contracts/contracts.module";
@@ -75,12 +76,13 @@ import { FirstMileModule } from './modules/first-mile/first-mile.module';
import { LastMileModule } from './modules/last-mile/last-mile.module';
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
import { ImportOperationsModule } from './modules/import-operations/import-operations.module';
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig, faydaConfig],
}),
ScheduleModule.forRoot(),
EventEmitterModule.forRoot(),
@@ -141,6 +143,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
LastMileModule,
InterchangeDocumentsModule,
ImportOperationsModule,
VerifaydaModule,
],
providers: [
EdrOrgSeeder,

View File

@@ -0,0 +1,126 @@
import { registerAs } from '@nestjs/config';
export interface FaydaJwk {
kty: 'RSA';
use?: string;
kid?: string;
alg?: string;
n: string;
e: string;
d: string;
p?: string;
q?: string;
dp?: string;
dq?: string;
qi?: string;
}
export type FaydaPlatform = 'WEB' | 'MOBILE';
export interface FaydaConfig {
enabled: boolean;
clientId: string;
authorizationEndpoint: string;
tokenEndpoint: string;
userInfoEndpoint: string;
/** OAuth redirect_uri sent to eSignet for MOBILE clients. */
redirectUri: string;
/** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */
webRedirectUri: string;
privateJwk: FaydaJwk;
scope: string;
acrValues: string;
claimsLocales: string;
sessionTtlMinutes: number;
}
const REQUIRED_VARS = [
'FAYDA_CLIENT_ID',
'FAYDA_AUTHORIZATION_ENDPOINT',
'FAYDA_TOKEN_ENDPOINT',
'FAYDA_USERINFO_ENDPOINT',
'FAYDA_PRIVATE_KEY_BASE64',
] as const;
function decodePrivateJwk(base64: string): FaydaJwk {
let jwk: unknown;
try {
const json = Buffer.from(base64, 'base64').toString('utf8');
jwk = JSON.parse(json);
} catch (err) {
throw new Error(
`FAYDA_PRIVATE_KEY_BASE64 is not valid Base64-encoded JSON: ${(err as Error).message}`,
);
}
if (!jwk || typeof jwk !== 'object') {
throw new Error('FAYDA_PRIVATE_KEY_BASE64 must decode to a JSON object');
}
const candidate = jwk as Partial<FaydaJwk>;
if (candidate.kty !== 'RSA') {
throw new Error('FAYDA_PRIVATE_KEY_BASE64 JWK must have kty="RSA"');
}
if (!candidate.n || !candidate.e || !candidate.d) {
throw new Error(
'FAYDA_PRIVATE_KEY_BASE64 JWK is missing required RSA private-key fields (n, e, d)',
);
}
return candidate as FaydaJwk;
}
export default registerAs('fayda', (): FaydaConfig => {
const enabled = (process.env.FAYDA_ENABLED ?? 'false').toLowerCase() === 'true';
// `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);
const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri;
if (!enabled) {
return {
enabled: false,
clientId: process.env.FAYDA_CLIENT_ID ?? '',
authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT ?? '',
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT ?? '',
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
redirectUri,
webRedirectUri,
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
scope,
acrValues,
claimsLocales,
sessionTtlMinutes: Number.isNaN(sessionTtl) || sessionTtl <= 0 ? 10 : sessionTtl,
};
}
const missing = REQUIRED_VARS.filter((name) => !process.env[name]);
if (missing.length > 0) {
throw new Error(
`Fayda integration is enabled (FAYDA_ENABLED=true) but the following env vars are missing: ${missing.join(', ')}`,
);
}
if (!redirectUri) {
throw new Error(
'Fayda integration is enabled but the redirect URI is missing: set FAYDA_REDIRECT_URI',
);
}
if (Number.isNaN(sessionTtl) || sessionTtl <= 0) {
throw new Error('FAYDA_SESSION_TTL_MINUTES must be a positive integer');
}
return {
enabled: true,
clientId: process.env.FAYDA_CLIENT_ID!,
authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT!,
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT!,
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
redirectUri,
webRedirectUri,
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
scope,
acrValues,
claimsLocales,
sessionTtlMinutes: sessionTtl,
};
});

View File

@@ -0,0 +1,44 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Session store for the VeriFayda 2.0 OIDC verification flow (ported from
* passenger-api). One row per started verification; `state` is the
* single-use CSRF token linking the eSignet redirect back to the session.
*/
export class AddFaydaVerificationSessions1890000000002 implements MigrationInterface {
name = "AddFaydaVerificationSessions1890000000002";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.fayda_verification_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
state varchar NOT NULL UNIQUE,
code_verifier varchar NOT NULL,
purpose varchar NOT NULL DEFAULT 'VERIFY',
platform varchar NOT NULL DEFAULT 'WEB',
save_to_account boolean NOT NULL DEFAULT false,
status varchar NOT NULL DEFAULT 'PENDING',
error_code varchar,
error_description text,
iam_user_id uuid,
expires_at timestamptz NOT NULL,
completed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_FAYDA_SESSIONS_EXPIRES_AT"
ON freight.fayda_verification_sessions (expires_at)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_FAYDA_SESSIONS_IAM_USER_ID"
ON freight.fayda_verification_sessions (iam_user_id)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.fayda_verification_sessions`);
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Track Fayda identity verification on drivers: whether the driver's
* identity was verified through VeriFayda and the OIDC subject it was
* verified against.
*/
export class AddDriverFaydaVerification1890000000003 implements MigrationInterface {
name = "AddDriverFaydaVerification1890000000003";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.drivers
ADD COLUMN IF NOT EXISTS fayda_verified boolean DEFAULT false,
ADD COLUMN IF NOT EXISTS fayda_sub varchar
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.drivers
DROP COLUMN IF EXISTS fayda_verified,
DROP COLUMN IF EXISTS fayda_sub
`);
}
}

View File

@@ -1,4 +1,4 @@
import { IsString, IsEmail, IsDateString, IsEnum, IsOptional, IsArray } from 'class-validator';
import { IsString, IsEmail, IsDateString, IsEnum, IsOptional, IsArray, IsBoolean } from 'class-validator';
import { DriverStatus } from '../entities/driver.entity';
export class CreateDriverDto {
@@ -42,4 +42,12 @@ export class CreateDriverDto {
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
@IsBoolean()
faydaVerified?: boolean;
@IsOptional()
@IsString()
faydaSub?: string;
}

View File

@@ -51,4 +51,11 @@ export class Driver extends BaseEntity {
@Column({ type: 'numeric', precision: 3, scale: 2, nullable: true })
rating?: number | null;
@Column({ name: 'fayda_verified', type: 'boolean', default: false, nullable: true })
faydaVerified?: boolean;
/** Fayda OIDC subject the identity was verified against. */
@Column({ name: 'fayda_sub', type: 'varchar', nullable: true })
faydaSub?: string | null;
}

View File

@@ -0,0 +1,49 @@
import { Column, Entity, Index } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
/**
* One row per started Fayda verification. Mirrors the passenger-api Prisma
* model `FaydaVerificationSession`, but stored in the freight schema via
* TypeORM. `state` is the single-use CSRF token that links the eSignet
* redirect back to this session.
*/
@Entity({ name: 'fayda_verification_sessions', schema: 'freight' })
@Index(['expiresAt'])
@Index(['iamUserId'])
export class FaydaVerificationSession extends BaseEntity {
@Column({ name: 'state', unique: true })
state!: string;
@Column({ name: 'code_verifier' })
codeVerifier!: string;
/** VERIFY | LOGIN */
@Column({ name: 'purpose', default: 'VERIFY' })
purpose!: string;
/** WEB | MOBILE — recorded for audit */
@Column({ name: 'platform', default: 'WEB' })
platform!: string;
@Column({ name: 'save_to_account', type: 'boolean', default: false })
saveToAccount!: boolean;
/** PENDING | COMPLETED | FAILED */
@Column({ name: 'status', default: 'PENDING' })
status!: string;
@Column({ name: 'error_code', type: 'varchar', nullable: true })
errorCode?: string | null;
@Column({ name: 'error_description', type: 'text', nullable: true })
errorDescription?: string | null;
@Column({ name: 'iam_user_id', type: 'uuid', nullable: true })
iamUserId?: string | null;
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt!: Date;
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
completedAt?: Date | null;
}

View File

@@ -0,0 +1,30 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { InjectDataSource } from '@nestjs/typeorm';
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { DataSource } from 'typeorm';
/**
* Like the IAM JwtGuard, but never rejects the request.
*
* When a valid IAM bearer token is present, `request.user` is populated with
* the package `TCurrentUser`. Missing or invalid tokens continue as guests.
*/
@Injectable()
export class OptionalJwtGuard extends IamJwtGuard implements CanActivate {
constructor(
reflector: Reflector,
@InjectDataSource() dataSource: DataSource,
) {
super(reflector, dataSource);
}
async canActivate(context: ExecutionContext): Promise<boolean> {
try {
await super.canActivate(context);
} catch {
context.switchToHttp().getRequest().user = undefined;
}
return true;
}
}

View File

@@ -0,0 +1,71 @@
import { exportJWK, generateKeyPair, importJWK, jwtVerify, type JWK } from 'jose';
import { generateClientAssertion } from './client-assertion.util';
describe('generateClientAssertion', () => {
let privateJwk: JWK;
let publicJwk: JWK;
beforeAll(async () => {
const kp = await generateKeyPair('RS256', { extractable: true });
privateJwk = await exportJWK(kp.privateKey);
publicJwk = await exportJWK(kp.publicKey);
});
it('produces a JWT verifiable with the matching public key', async () => {
const jwt = await generateClientAssertion({
clientId: 'edr-passenger-test',
audience: 'https://esignet.example.com/token',
privateJwk,
});
const verifier = await importJWK(publicJwk, 'RS256');
const { payload, protectedHeader } = await jwtVerify(jwt, verifier, {
issuer: 'edr-passenger-test',
subject: 'edr-passenger-test',
audience: 'https://esignet.example.com/token',
});
expect(protectedHeader.alg).toBe('RS256');
expect(protectedHeader.typ).toBe('JWT');
expect(payload.iss).toBe('edr-passenger-test');
expect(payload.sub).toBe('edr-passenger-test');
expect(payload.aud).toBe('https://esignet.example.com/token');
expect(typeof payload.iat).toBe('number');
expect(typeof payload.exp).toBe('number');
});
it('defaults exp to 120 seconds after iat', async () => {
const jwt = await generateClientAssertion({
clientId: 'c',
audience: 'https://a/token',
privateJwk,
});
const verifier = await importJWK(publicJwk, 'RS256');
const { payload } = await jwtVerify(jwt, verifier);
expect(payload.exp! - payload.iat!).toBe(120);
});
it('honors a custom expiresIn', async () => {
const jwt = await generateClientAssertion({
clientId: 'c',
audience: 'https://a/token',
privateJwk,
expiresIn: '5m',
});
const verifier = await importJWK(publicJwk, 'RS256');
const { payload } = await jwtVerify(jwt, verifier);
expect(payload.exp! - payload.iat!).toBe(300);
});
it('fails verification against a wrong audience', async () => {
const jwt = await generateClientAssertion({
clientId: 'c',
audience: 'https://a/token',
privateJwk,
});
const verifier = await importJWK(publicJwk, 'RS256');
await expect(
jwtVerify(jwt, verifier, { audience: 'https://other/token' }),
).rejects.toThrow();
});
});

View File

@@ -0,0 +1,22 @@
import { SignJWT, importJWK, type JWK } from 'jose';
export interface GenerateClientAssertionInput {
clientId: string;
audience: string;
privateJwk: JWK;
expiresIn?: string;
}
export async function generateClientAssertion(
input: GenerateClientAssertionInput,
): Promise<string> {
const privateKey = await importJWK(input.privateJwk, 'RS256');
return new SignJWT({})
.setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
.setIssuer(input.clientId)
.setSubject(input.clientId)
.setAudience(input.audience)
.setIssuedAt()
.setExpirationTime(input.expiresIn ?? '2m')
.sign(privateKey);
}

View File

@@ -0,0 +1,65 @@
import { createHash } from 'crypto';
import {
base64Url,
generateCodeChallenge,
generateCodeVerifier,
generateState,
} from './pkce.util';
describe('pkce.util', () => {
describe('base64Url', () => {
it('strips padding and replaces + and / with - and _', () => {
const input = Buffer.from([0xfb, 0xff, 0xbf, 0xfe]);
const out = base64Url(input);
expect(out).not.toMatch(/[+/=]/);
});
});
describe('generateCodeVerifier', () => {
it('returns a base64url-safe string', () => {
expect(generateCodeVerifier()).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('produces unique values across calls', () => {
const a = generateCodeVerifier();
const b = generateCodeVerifier();
expect(a).not.toEqual(b);
});
it('produces at least 43 characters (RFC 7636 minimum)', () => {
expect(generateCodeVerifier().length).toBeGreaterThanOrEqual(43);
});
});
describe('generateCodeChallenge', () => {
it('equals base64url(sha256(verifier))', () => {
const verifier = 'fixed-test-verifier';
const expected = createHash('sha256')
.update(verifier)
.digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
expect(generateCodeChallenge(verifier)).toBe(expected);
});
it('is deterministic for the same verifier', () => {
const verifier = generateCodeVerifier();
expect(generateCodeChallenge(verifier)).toBe(generateCodeChallenge(verifier));
});
it('differs for different verifiers', () => {
expect(generateCodeChallenge('a')).not.toBe(generateCodeChallenge('b'));
});
});
describe('generateState', () => {
it('returns a base64url-safe string', () => {
expect(generateState()).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('produces unique values across calls', () => {
expect(generateState()).not.toEqual(generateState());
});
});
});

View File

@@ -0,0 +1,21 @@
import { createHash, randomBytes } from 'crypto';
export function base64Url(buffer: Buffer): string {
return buffer
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
export function generateCodeVerifier(): string {
return base64Url(randomBytes(64));
}
export function generateCodeChallenge(codeVerifier: string): string {
return base64Url(createHash('sha256').update(codeVerifier).digest());
}
export function generateState(): string {
return base64Url(randomBytes(32));
}

View File

@@ -0,0 +1,107 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { OptionalJwtGuard } from './optional-jwt.guard';
import {
CompleteVerificationResultDto,
StartVerificationDto,
VerifaydaCallbackDto,
VerificationStatusDto,
} from './verifayda.dto';
import { VerifaydaService } from './verifayda.service';
/** Minimal slices of the Express req we touch (avoids a hard dependency on
* `@types/express`, which isn't resolved in this package). */
interface RequestWithOptionalUser {
user?: TCurrentUser;
}
interface RequestWithUser {
user: TCurrentUser;
}
@ApiTags('Fayda Verification')
@Controller('fayda/verification')
export class VerifaydaController {
constructor(private readonly service: VerifaydaService) {}
@Post('start')
@IsPublic()
@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.
- **VERIFY** (default): the user proves their identity and \`/complete\` returns the verified attributes (name, email, phone, dob, gender).
- **LOGIN**: \`/complete\` resolves/creates the user and returns a JWT.
- 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 ?? 'VERIFY',
platform: dto.platform ?? 'WEB',
userId: req.user?.id,
wantsPasswordSetup: dto.wantsPasswordSetup ?? false,
});
return { authorizationUrl };
}
@Get('complete')
@IsPublic()
@ApiOperation({
summary: 'Complete a verification (Fayda redirect / client callback lands here)',
description: `This is the registered Fayda \`redirect_uri\`. Fayda redirects the browser here with \`?code&state\``,
})
@ApiOkResponse({ type: CompleteVerificationResultDto })
async complete(
@Query() dto: VerifaydaCallbackDto,
): Promise<CompleteVerificationResultDto> {
return this.service.completeVerification(dto);
}
@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<VerificationStatusDto> {
return this.service.getVerificationStatus(req.user.id);
}
}

View File

@@ -0,0 +1,105 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsString } from 'class-validator';
export class StartVerificationDto {
@ApiPropertyOptional({
enum: ['LOGIN', 'VERIFY'],
default: 'VERIFY',
description:
'Reason for verification. VERIFY returns the verified identity attributes; LOGIN resolves/creates a user and returns a JWT.',
})
@IsOptional()
@IsIn(['LOGIN', 'VERIFY'])
purpose?: 'LOGIN' | 'VERIFY';
@ApiPropertyOptional({
enum: ['WEB', 'MOBILE'],
default: 'WEB',
description:
'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB uses FAYDA_WEB_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. Both land on the same /complete endpoint with identical handling.',
})
@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 {
@ApiProperty({ enum: ['LOGIN', 'VERIFY'] })
purpose!: 'LOGIN' | 'VERIFY';
@ApiProperty() verified!: boolean;
@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).',
})
user?: {
id: string;
email: string;
role: string;
passengerId?: string;
agentId?: string;
};
@ApiPropertyOptional({ description: 'Verified full name from Fayda (VERIFY flow).' })
fullName?: string;
@ApiPropertyOptional({ description: 'Verified email from Fayda (VERIFY flow).' })
email?: string;
@ApiPropertyOptional({ description: 'Verified phone number from Fayda (VERIFY flow).' })
phoneNumber?: string;
@ApiPropertyOptional({
description: 'Verified date of birth from Fayda, ISO yyyy-MM-dd (VERIFY flow).',
})
birthdate?: string;
@ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' })
gender?: string;
@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 {
@ApiPropertyOptional() @IsOptional() @IsString() code?: string;
@ApiPropertyOptional() @IsOptional() @IsString() state?: string;
@ApiPropertyOptional() @IsOptional() @IsString() error?: string;
@ApiPropertyOptional() @IsOptional() @IsString() error_description?: string;
}
export class VerificationStatusDto {
@ApiProperty() verified!: boolean;
@ApiPropertyOptional() verifiedAt?: Date;
@ApiPropertyOptional() fullName?: string;
}

View File

@@ -0,0 +1,19 @@
import { BadGatewayException, ConflictException } from '@nestjs/common';
export class FaydaTokenExchangeException extends BadGatewayException {
constructor(message = 'Fayda token exchange failed') {
super({ code: 'FAYDA_TOKEN_EXCHANGE_FAILED', message });
}
}
export class FaydaUserInfoException extends BadGatewayException {
constructor(message = 'Fayda userinfo fetch failed') {
super({ code: 'FAYDA_USERINFO_FAILED', message });
}
}
export class FaydaIdentityConflictException extends ConflictException {
constructor(message = 'This Fayda identity is already linked to another account') {
super({ code: 'FAYDA_IDENTITY_CONFLICT', message });
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { VerifaydaController } from './verifayda.controller';
import { VerifaydaService } from './verifayda.service';
import { FaydaVerificationSession } from './entities/fayda-verification-session.entity';
@Module({
imports: [TypeOrmModule.forFeature([FaydaVerificationSession])],
controllers: [VerifaydaController],
providers: [VerifaydaService],
exports: [VerifaydaService],
})
export class VerifaydaModule {}

View File

@@ -0,0 +1,597 @@
import {
BadRequestException,
Injectable,
Logger,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { generateToken, generateRefreshToken } from '@tria-plc/api-common/utils/token';
import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config';
import { FaydaVerificationSession } from './entities/fayda-verification-session.entity';
import {
generateCodeChallenge,
generateCodeVerifier,
generateState,
} from './utils/pkce.util';
import { generateClientAssertion } from './utils/client-assertion.util';
import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto';
import {
FaydaTokenExchangeException,
FaydaUserInfoException,
} from './verifayda.errors';
import {
FaydaTokenResponse,
FaydaUserInfo,
NormalizedFaydaUserInfo,
VerifaydaPurpose,
} from './verifayda.types';
export interface StartVerificationInput {
purpose: VerifaydaPurpose;
platform?: FaydaPlatform;
userId?: string; // iamUserId of the authenticated user, if any
wantsPasswordSetup?: boolean;
}
export interface FaydaUserSummary {
id: string;
email: string;
role: string;
}
/**
* Result of completing a verification. `verified` is always true on success.
* LOGIN additionally returns a JWT + user; VERIFY returns the verified identity
* attributes (name, email, phone, dob, gender) for the caller to consume.
*/
export interface CompleteVerificationResult {
purpose: VerifaydaPurpose;
verified: boolean;
token?: string;
refreshToken?: string;
requiresPassword?: boolean;
promptPasswordSetup?: boolean;
iamUserId?: string;
user?: FaydaUserSummary;
fullName?: string;
email?: string;
phoneNumber?: string;
birthdate?: string;
gender?: string;
userDataSaved?: boolean;
}
@Injectable()
export class VerifaydaService {
private readonly logger = new Logger(VerifaydaService.name);
private readonly faydaConfig: FaydaConfig;
constructor(
private readonly config: ConfigService,
@InjectRepository(FaydaVerificationSession)
private readonly sessionRepo: Repository<FaydaVerificationSession>,
@InjectDataSource() private readonly dataSource: DataSource,
) {
const fayda = this.config.get<FaydaConfig>('fayda');
if (!fayda) {
throw new Error('Fayda config namespace not registered');
}
this.faydaConfig = fayda;
}
// ==========================================================================
// OIDC flow
// ==========================================================================
async startVerification(input: StartVerificationInput): Promise<string> {
if (!this.faydaConfig.enabled) {
throw new ServiceUnavailableException({
code: 'FAYDA_DISABLED',
message: 'Fayda integration is not enabled',
});
}
const state = generateState();
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const expiresAt = new Date(
Date.now() + this.faydaConfig.sessionTtlMinutes * 60_000,
);
await this.sessionRepo.save(
this.sessionRepo.create({
state,
codeVerifier,
purpose: input.purpose,
platform: input.platform ?? 'WEB',
saveToAccount: input.wantsPasswordSetup ?? false,
iamUserId: input.userId ?? null,
expiresAt,
}),
);
this.logger.log(
`Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'}`,
);
return this.buildAuthorizationUrl({
state,
codeChallenge,
redirectUri: this.redirectUriForPlatform(input.platform ?? 'WEB'),
});
}
/** WEB clients use `webRedirectUri`; MOBILE uses the base `redirectUri`. */
private redirectUriForPlatform(platform?: FaydaPlatform): string {
return platform === 'MOBILE'
? this.faydaConfig.redirectUri
: this.faydaConfig.webRedirectUri;
}
async completeVerification(
query: VerifaydaCallbackDto,
): Promise<CompleteVerificationResult> {
if (query.error) {
this.logger.warn(`Fayda callback returned error: ${query.error}`);
if (query.state) {
await this.markSessionFailed(
query.state,
query.error,
query.error_description,
);
}
throw new BadRequestException({
code: 'FAYDA_AUTH_ERROR',
message: query.error,
description: query.error_description,
});
}
if (!query.code || !query.state) {
throw new BadRequestException({
code: 'FAYDA_MISSING_PARAMETERS',
message: 'code and state are required',
});
}
const session = await this.sessionRepo.findOne({
where: { state: query.state },
});
if (!session || session.status !== 'PENDING') {
this.logger.warn('Fayda complete with unknown or non-pending state');
throw new BadRequestException({
code: 'FAYDA_INVALID_STATE',
message: 'Verification session is invalid or already used',
});
}
if (session.expiresAt.getTime() < Date.now()) {
await this.markSessionFailed(query.state, 'session_expired');
throw new BadRequestException({
code: 'FAYDA_SESSION_EXPIRED',
message: 'Verification session has expired; start again',
});
}
try {
const tokens = await this.exchangeCodeForTokens(
query.code,
session.codeVerifier,
this.redirectUriForPlatform(session.platform as FaydaPlatform),
);
const userInfo = await this.fetchUserInfo(tokens.access_token);
const normalized = this.normalizeUserInfo(userInfo);
if (!normalized.sub) {
throw new FaydaUserInfoException('Fayda userinfo missing required sub');
}
let result: CompleteVerificationResult;
if (session.purpose === 'LOGIN') {
const { userId } = await this.handleLoginSuccess(normalized);
const login = await this.issueLoginToken(userId);
result = { purpose: 'LOGIN', verified: true, ...login };
} else {
// 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,
fullName: normalized.fullName,
email: normalized.email,
phoneNumber: normalized.phoneNumber,
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),
};
}
await this.sessionRepo.update(session.id, {
status: 'COMPLETED',
completedAt: new Date(),
codeVerifier: '',
});
this.logger.log(
`Fayda verification completed: purpose=${session.purpose} platform=${session.platform}`,
);
return result;
} catch (err) {
const reason = this.classifyFailureReason(err);
this.logger.error(
`Fayda verification failed: reason=${reason} message=${(err as Error).message}`,
);
await this.markSessionFailed(
query.state,
reason,
(err as Error).message,
);
throw err;
}
}
private async issueLoginToken(
_userId: string,
): Promise<{ token: string; user: FaydaUserSummary }> {
throw new UnauthorizedException({
code: 'FAYDA_LOGIN_MIGRATED_TO_IAM',
message: 'Fayda login tokens are issued by the IAM package auth endpoints.',
});
}
async getVerificationStatus(iamUserId: string): Promise<VerificationStatusDto> {
const rows = await this.dataSource.query<{ verified_by: string | null; updated_at: Date | null; name: { en: string; am: string } | null }[]>(
`SELECT verified_by, updated_at, name FROM iam.users WHERE id = $1 LIMIT 1`,
[iamUserId],
);
const iam = rows[0] ?? null;
const faydaVerified = iam?.verified_by === 'fayda';
const faydaVerifiedAt = faydaVerified && iam?.updated_at ? new Date(iam.updated_at) : undefined;
const fullName = iam?.name?.en ?? iam?.name?.am ?? undefined;
return { verified: faydaVerified, verifiedAt: faydaVerifiedAt, fullName };
}
// ==========================================================================
// OIDC internals
// ==========================================================================
private buildAuthorizationUrl(args: {
state: string;
codeChallenge: string;
redirectUri: string;
}): string {
const params = new URLSearchParams({
client_id: this.faydaConfig.clientId,
response_type: 'code',
redirect_uri: args.redirectUri,
scope: this.faydaConfig.scope,
state: args.state,
code_challenge: args.codeChallenge,
code_challenge_method: 'S256',
acr_values: this.faydaConfig.acrValues,
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: true },
birthdate: { essential: true },
gender: { essential: true },
address: { essential: true },
nationality: { essential: true },
picture: { essential: true },
},
id_token: {},
};
params.set('claims', JSON.stringify(claims));
return `${this.faydaConfig.authorizationEndpoint}?${params.toString()}`;
}
private async exchangeCodeForTokens(
code: string,
codeVerifier: string,
redirectUri: string,
): Promise<FaydaTokenResponse> {
const clientAssertion = await generateClientAssertion({
clientId: this.faydaConfig.clientId,
audience: this.faydaConfig.tokenEndpoint,
privateJwk: this.faydaConfig.privateJwk,
});
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: this.faydaConfig.clientId,
client_assertion_type:
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: clientAssertion,
code_verifier: codeVerifier,
});
const response = await fetch(this.faydaConfig.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!response.ok) {
let detail = '';
try {
detail = await response.text();
} catch {
// ignore
}
throw new FaydaTokenExchangeException(
`Fayda token endpoint returned ${response.status}${detail ? `: ${detail}` : ''}`,
);
}
return (await response.json()) as FaydaTokenResponse;
}
private async fetchUserInfo(accessToken: string): Promise<FaydaUserInfo> {
const response = await fetch(this.faydaConfig.userInfoEndpoint, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
throw new FaydaUserInfoException(
`Fayda userinfo endpoint returned ${response.status}`,
);
}
const contentType = response.headers.get('content-type') ?? '';
const raw = await response.text();
if (contentType.includes('application/json')) {
return JSON.parse(raw) as FaydaUserInfo;
}
// Signed JWT response — decode payload (signature verification = production TODO)
if (raw.split('.').length === 3) {
const payloadB64 = raw.split('.')[1];
const normalizedB64 = payloadB64.replace(/-/g, '+').replace(/_/g, '/');
const json = Buffer.from(normalizedB64, 'base64').toString('utf8');
return JSON.parse(json) as FaydaUserInfo;
}
throw new FaydaUserInfoException(
'Unsupported Fayda userinfo response format',
);
}
private normalizeUserInfo(raw: FaydaUserInfo): NormalizedFaydaUserInfo {
const nameEn = raw['name#en'] as string | undefined;
const nameAm = raw['name#am'] as string | undefined;
const genderEn = raw['gender#en'] as string | undefined;
const genderAm = raw['gender#am'] as string | undefined;
const addressEn = raw['address#en'] as string | undefined;
const addressAm = raw['address#am'] as string | undefined;
const rawPhone = (raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone) as string | undefined;
return {
sub: raw.sub,
fullName: (raw.name as string | undefined) ?? nameEn ?? nameAm,
phoneNumber: rawPhone ? this.standardizePhoneNumber(rawPhone) : undefined,
rawPhoneNumber: rawPhone,
email: raw.email as string | undefined,
gender: genderEn ?? genderAm ?? (raw.gender as string | undefined),
birthdate: raw.birthdate as string | undefined,
picture: raw.picture as string | undefined,
nameEn,
nameAm,
genderEn,
genderAm,
addressEn,
addressAm,
};
}
private standardizePhoneNumber(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}`;
}
// LOGIN via Fayda is 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,
): Promise<{ userId: string }> {
throw new UnauthorizedException({
code: 'FAYDA_LOGIN_MIGRATED_TO_IAM',
message: 'Fayda login tokens are issued by the IAM package at /v1/auth/fayda endpoints.',
});
}
private async upsertIamUser(
normalized: NormalizedFaydaUserInfo,
): Promise<{ iamUserId: string | null; userDataSaved: boolean }> {
try {
const iamMetadata = {
sub: normalized.sub,
address: { am: normalized.addressAm ?? '', en: normalized.addressEn ?? '' },
email: normalized.email ?? '',
gender: { am: normalized.genderAm ?? '', en: normalized.genderEn ?? '' },
name: { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' },
phoneNumber: normalized.rawPhoneNumber ?? '',
};
// 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 };
}
// Step 2 — existing user by phone or email, not yet Fayda-verified
const conditions: string[] = [];
const params: unknown[] = [];
if (normalized.phoneNumber) {
params.push(normalized.phoneNumber);
conditions.push(`phone_number = $${params.length}`);
}
if (normalized.email) {
params.push(normalized.email);
conditions.push(`email = $${params.length}`);
}
if (conditions.length > 0) {
const byContact = await this.dataSource.query<{ id: string }[]>(
`SELECT id FROM iam.users WHERE ${conditions.join(' OR ')} LIMIT 1`,
params,
);
if (byContact.length > 0) {
const existingId = byContact[0].id;
await this.dataSource.query(
`UPDATE iam.users
SET metadata = COALESCE(metadata, '{}'::jsonb) || $1::jsonb,
verified_by = 'fayda',
updated_at = NOW()
WHERE id = $2`,
[JSON.stringify(iamMetadata), existingId],
);
return { iamUserId: existingId, userDataSaved: true };
}
}
// Step 3 — new user
const name = { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' };
const username = normalized.phoneNumber ?? normalized.email ?? normalized.sub;
const inserted = await this.dataSource.query<{ id: string }[]>(
`INSERT INTO iam.users (
id, name, username, email, phone_number, metadata,
user_type, status, is_active, has_set_password,
is_phone_number_verified, verified_by,
created_at, updated_at
) VALUES (
gen_random_uuid(), $1::jsonb, $2, $3, $4, $5::jsonb,
'individual', 'submitted', true, false,
false, 'fayda',
NOW(), NOW()
) RETURNING id`,
[
JSON.stringify(name),
username,
normalized.email ?? null,
normalized.phoneNumber ?? null,
JSON.stringify(iamMetadata),
],
);
return { iamUserId: inserted[0].id, userDataSaved: true };
} catch (err) {
this.logger.error(`Fayda IAM upsert failed: ${(err as Error).message}`);
return { iamUserId: null, userDataSaved: false };
}
}
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,
errorDescription?: string,
): Promise<void> {
await this.sessionRepo.update(
{ state, status: 'PENDING' },
{
status: 'FAILED',
errorCode,
errorDescription: errorDescription ?? null,
completedAt: new Date(),
codeVerifier: '',
},
);
}
private classifyFailureReason(err: unknown): string {
if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed';
if (err instanceof FaydaUserInfoException) return 'userinfo_failed';
return 'verification_failed';
}
}

View File

@@ -0,0 +1,45 @@
export type VerifaydaPurpose = 'LOGIN' | 'VERIFY';
export interface FaydaTokenResponse {
access_token: string;
id_token?: string;
token_type: string;
expires_in?: number;
scope?: string;
}
export interface FaydaUserInfo {
sub: string;
name?: string;
'name#en'?: string;
'name#am'?: string;
phone_number?: string;
'phone_number#en'?: string;
'phone_number#am'?: string;
phone?: string;
email?: string;
gender?: string;
birthdate?: string;
picture?: string;
address?: Record<string, unknown>;
[key: string]: unknown;
}
export interface NormalizedFaydaUserInfo {
sub: string;
// Convenience / display fields
fullName?: string;
phoneNumber?: string; // standardized e.g. +251911234567
email?: string;
gender?: string;
birthdate?: string;
picture?: string;
// Raw localized fields — preserved for IAM-identical writes
nameEn?: string;
nameAm?: string;
genderEn?: string;
genderAm?: string;
addressEn?: string;
addressAm?: string;
rawPhoneNumber?: string; // unstandardized, stored in IAM metadata
}

View File

@@ -93,6 +93,7 @@ import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage";
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
import { HealthCheck } from "./features/health/HealthCheck";
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
@@ -463,6 +464,7 @@ const App = () => {
<Routes>
<Route path="/auth" element={<LoginPage />} />
<Route path="/um/*" element={<UserManagementHostPage />} />
<Route path="/callback" element={<FaydaCallbackPage />} />
<Route path="*" element={<Navigate to="/auth" replace />} />
</Routes>
);
@@ -472,6 +474,7 @@ const App = () => {
<Routes>
<Route path="/um/*" element={<UserManagementHostPage />} />
<Route path="/health" element={<HealthCheck />} />
<Route path="/callback" element={<FaydaCallbackPage />} />
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<DashboardShell />}>

View File

@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import { Loader2, Calendar } from "lucide-react";
import { Loader2, Calendar, ShieldCheck } from "lucide-react";
import {
Alert,
Badge,
Button,
Group,
Modal,
@@ -20,6 +22,10 @@ import {
type FleetFormFieldDef,
} from "@/pages/fleet/config/resources";
import type { FleetRecord } from "@/services/fleet/fleet.service";
import {
verifaydaService,
type FaydaCallbackMessage,
} from "@/services/verifayda.service";
export interface FleetFormDialogProps {
open: boolean;
@@ -31,6 +37,12 @@ export interface FleetFormDialogProps {
isSubmitting: boolean;
selectOptionsLoading?: boolean;
onSubmit: (values: Record<string, unknown>) => void;
/**
* Show a "Verify with Fayda" step: opens the eSignet popup and prefills
* firstName/lastName/email/phoneNumber/dateOfBirth from the verified
* identity, stamping faydaVerified + faydaSub on the payload.
*/
verifyWithFayda?: boolean;
}
const buildInitialValues = (
@@ -72,9 +84,12 @@ const FleetFormDialog = ({
isSubmitting,
selectOptionsLoading,
onSubmit,
verifyWithFayda,
}: FleetFormDialogProps) => {
const [values, setValues] = useState<Record<string, unknown>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [faydaLoading, setFaydaLoading] = useState(false);
const [faydaError, setFaydaError] = useState<string | null>(null);
// Seed the form ONLY when the dialog opens or the edited record changes — NOT
// when `fields`/`emptyValues` get new object refs (they're rebuilt whenever the
@@ -87,10 +102,85 @@ const FleetFormDialog = ({
if (open) {
setValues(buildInitialValues(fields, emptyValues, initialRecord));
setErrors({});
setFaydaError(null);
setFaydaLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, recordId]);
// Receive the ?code&state relayed by the /callback popup, exchange it for
// the verified identity, and prefill the matching form fields.
useEffect(() => {
if (!open || !verifyWithFayda) return;
const onMessage = async (event: MessageEvent<FaydaCallbackMessage>) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== "fayda-callback") return;
if (event.data.error) {
setFaydaLoading(false);
setFaydaError(event.data.errorDescription ?? event.data.error);
return;
}
if (!event.data.code || !event.data.state) return;
try {
const result = await verifaydaService.complete(event.data.code, event.data.state);
if (!result.verified) {
setFaydaError("Identity could not be verified");
return;
}
const nameParts = (result.fullName ?? "").trim().split(/\s+/).filter(Boolean);
const [firstName, ...rest] = nameParts;
setValues((current) => ({
...current,
...(firstName ? { firstName } : {}),
...(rest.length ? { lastName: rest.join(" ") } : {}),
...(result.email ? { email: result.email } : {}),
...(result.phoneNumber ? { phoneNumber: result.phoneNumber } : {}),
...(result.birthdate ? { dateOfBirth: result.birthdate } : {}),
faydaVerified: true,
...(result.iamUserId ? { faydaSub: result.iamUserId } : {}),
}));
setFaydaError(null);
} catch (err) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
(err instanceof Error ? err.message : "Verification failed");
setFaydaError(message);
} finally {
setFaydaLoading(false);
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [open, verifyWithFayda]);
const handleFaydaVerify = async () => {
setFaydaError(null);
setFaydaLoading(true);
try {
const { authorizationUrl } = await verifaydaService.start();
const popup = window.open(
authorizationUrl,
"fayda-verify",
"width=480,height=760,noopener=no",
);
if (!popup) {
setFaydaLoading(false);
setFaydaError("Pop-up blocked — allow pop-ups for this site and retry.");
}
// Loading stays on until the popup posts back; reopening the dialog resets it.
} catch (err) {
setFaydaLoading(false);
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
(err instanceof Error ? err.message : "Could not start verification");
setFaydaError(message);
}
};
const faydaVerified = values.faydaVerified === true;
const shortFields = useMemo(
() => fields.filter((f) => f.type !== "textarea"),
[fields],
@@ -333,6 +423,39 @@ const FleetFormDialog = ({
centered
>
<Stack gap="md">
{verifyWithFayda && (
<Group justify="space-between" wrap="nowrap">
{faydaVerified ? (
<Badge
color="green"
variant="light"
size="lg"
leftSection={<ShieldCheck size={14} />}
>
Identity verified with Fayda
</Badge>
) : (
<Text size="sm" c="dimmed">
Verify the driver's identity with Fayda to prefill their details.
</Text>
)}
<Button
variant={faydaVerified ? "default" : "light"}
color="edr-green"
size="xs"
leftSection={<ShieldCheck size={14} />}
loading={faydaLoading}
onClick={handleFaydaVerify}
>
{faydaVerified ? "Re-verify" : "Verify with Fayda"}
</Button>
</Group>
)}
{verifyWithFayda && faydaError && (
<Alert color="red" variant="light">
{faydaError}
</Alert>
)}
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{shortFields.map(renderField)}
</SimpleGrid>

View File

@@ -4,7 +4,7 @@ import { Badge, Text } from "@mantine/core";
import type { ColumnFormat } from "@/pages/ruleEngine/config/resources";
import { formatCell as formatRuleEngineCell } from "@/components/ruleEngine/ruleEngineFormat";
export type FleetColumnFormat = ColumnFormat | "statusBadge";
export type FleetColumnFormat = ColumnFormat | "statusBadge" | "verifiedBadge";
const optionLabelMap = new Map<string, Map<string, string>>();
@@ -20,6 +20,16 @@ export const formatFleetCell = (
format?: FleetColumnFormat,
accessorKey?: string,
): ReactNode => {
if (format === "verifiedBadge") {
return value === true ? (
<Badge variant="light" color="green" size="sm" radius="md">
Verified
</Badge>
) : (
<Text size="sm" c="dimmed"></Text>
);
}
if (format === "statusBadge") {
const status = value == null || value === "" ? "—" : String(value);
const getStatusColor = (st: string): string => {

View File

@@ -0,0 +1,56 @@
import { useEffect, useState } from "react";
import { Center, Loader, Stack, Text } from "@mantine/core";
import type { FaydaCallbackMessage } from "@/services/verifayda.service";
/**
* Landing page for the eSignet redirect_uri (FAYDA_WEB_REDIRECT_URI →
* http://localhost:5183/callback). Runs inside the verification popup:
* relays ?code&state (or ?error) to the window that opened it via
* postMessage, then closes itself. The opener performs the /complete call
* so the single-use session is only consumed once, in one place.
*/
const FaydaCallbackPage = () => {
const [standalone, setStandalone] = useState(false);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const message: FaydaCallbackMessage = {
type: "fayda-callback",
code: params.get("code") ?? undefined,
state: params.get("state") ?? undefined,
error: params.get("error") ?? undefined,
errorDescription: params.get("error_description") ?? undefined,
};
if (window.opener && window.opener !== window) {
(window.opener as Window).postMessage(message, window.location.origin);
window.close();
} else {
// Opened as a full-page redirect instead of a popup — nothing to relay to.
setStandalone(true);
}
}, []);
return (
<Center h="100vh">
<Stack align="center" gap="sm">
{standalone ? (
<>
<Text fw={600}>Verification window lost its parent page</Text>
<Text size="sm" c="dimmed">
Close this tab and restart the verification from the form.
</Text>
</>
) : (
<>
<Loader size="sm" />
<Text size="sm" c="dimmed">Completing Fayda verification</Text>
</>
)}
</Stack>
</Center>
);
};
export default FaydaCallbackPage;

View File

@@ -510,6 +510,7 @@ const FleetResourcePage = () => {
isSubmitting={create.isPending || update.isPending}
selectOptionsLoading={selectOptionsLoading}
onSubmit={handleFormSubmit}
verifyWithFayda={Boolean(config.faydaVerification)}
/>
<Modal

View File

@@ -29,6 +29,7 @@ export const driversConfig: FleetResourceConfig = {
options: DRIVER_STATUS_OPTIONS,
},
],
faydaVerification: true,
searchKeys: ["firstName", "lastName", "email", "phoneNumber", "licenseNumber", "status"],
columns: [
{ id: "licenseNumber", header: "License Number", accessorKey: "licenseNumber", format: "code", size: 140 },
@@ -38,6 +39,7 @@ export const driversConfig: FleetResourceConfig = {
{ id: "phoneNumber", header: "Phone", accessorKey: "phoneNumber", format: "code", size: 120 },
{ id: "licenseExpiryDate", header: "License Expiry", accessorKey: "licenseExpiryDate", format: "code", size: 130 },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
{ id: "faydaVerified", header: "Fayda", accessorKey: "faydaVerified", format: "verifiedBadge", size: 90 },
],
formFields: [
{ name: "licenseNumber", label: "License Number", type: "text", required: true },

View File

@@ -33,7 +33,7 @@ export interface FleetResourceColumn {
id: string;
header: string;
accessorKey: string;
format?: ColumnFormat | "statusBadge";
format?: ColumnFormat | "statusBadge" | "verifiedBadge";
size?: number;
}
@@ -73,6 +73,8 @@ export interface FleetResourceConfig {
cardCodeKey?: string;
cardSubtitleKey?: string;
searchKeys: string[];
/** Offer Fayda identity verification in the add/edit form (drivers). */
faydaVerification?: boolean;
}
export const FLEET_BASE_PATH_BY_SLUG: Record<FleetResourceSlug, string> = {

View File

@@ -26,6 +26,8 @@ export interface Driver {
address?: string | null;
emergencyContact?: string | null;
notes?: string | null;
faydaVerified?: boolean;
faydaSub?: string | null;
totalTrips: number;
rating: number;
createdAt: string;

View File

@@ -0,0 +1,46 @@
import { api as apiClient } from '../auth/http';
export interface FaydaStartResponse {
authorizationUrl: string;
}
export interface FaydaCompleteResult {
purpose: 'LOGIN' | 'VERIFY';
verified: boolean;
fullName?: string;
email?: string;
phoneNumber?: string;
/** ISO yyyy-MM-dd */
birthdate?: string;
gender?: string;
iamUserId?: string;
userDataSaved?: boolean;
}
/** Message posted from the /callback popup back to the opener window. */
export interface FaydaCallbackMessage {
type: 'fayda-callback';
code?: string;
state?: string;
error?: string;
errorDescription?: string;
}
export const verifaydaService = {
/** Returns the eSignet authorize URL to open in a popup. */
start: () =>
apiClient
.post<FaydaStartResponse>('/fayda/verification/start', {
purpose: 'VERIFY',
platform: 'WEB',
})
.then((r) => r.data),
/** Exchange the callback code+state for the verified identity attributes. */
complete: (code: string, state: string) =>
apiClient
.get<FaydaCompleteResult>(
`/fayda/verification/complete?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`,
)
.then((r) => r.data),
};