mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 16:40:56 +00:00
fix fayda
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
126
apps/edr-freight-api/src/config/fayda.config.ts
Normal file
126
apps/edr-freight-api/src/config/fayda.config.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
105
apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts
Normal file
105
apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
597
apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts
Normal file
597
apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 />}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
@@ -510,6 +510,7 @@ const FleetResourcePage = () => {
|
||||
isSubmitting={create.isPending || update.isPending}
|
||||
selectOptionsLoading={selectOptionsLoading}
|
||||
onSubmit={handleFormSubmit}
|
||||
verifyWithFayda={Boolean(config.faydaVerification)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
Reference in New Issue
Block a user