mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
Merge branch 'feat/veriFayda-integration' into alpha
This commit is contained in:
@@ -88,9 +88,19 @@ IAM_ENABLED=false
|
|||||||
IAM_API_URL=https://iam.tria-plc.com/api
|
IAM_API_URL=https://iam.tria-plc.com/api
|
||||||
IAM_API_KEY=
|
IAM_API_KEY=
|
||||||
|
|
||||||
# Verifayda 2.0 Configuration (Ethiopian National ID Verification)
|
# --- VeriFayda 2.0 (eSignet) OIDC integration ---
|
||||||
VERIFAYDA_ENABLED=false
|
FAYDA_ENABLED=true
|
||||||
VERIFAYDA_API_URL=https://api.verifayda.gov.et/v2
|
FAYDA_CLIENT_ID=
|
||||||
VERIFAYDA_API_KEY=
|
FAYDA_AUTHORIZATION_ENDPOINT=
|
||||||
|
FAYDA_TOKEN_ENDPOINT=
|
||||||
|
FAYDA_USERINFO_ENDPOINT=
|
||||||
|
# Base64 of the RSA private JWK (JSON). Secret — never commit a real value.
|
||||||
|
FAYDA_PRIVATE_KEY_BASE64=
|
||||||
|
FAYDA_REDIRECT_URI=
|
||||||
|
# Optional (defaults shown)
|
||||||
|
FAYDA_SCOPE=openid profile email
|
||||||
|
FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
|
||||||
|
FAYDA_CLAIMS_LOCALES=en am
|
||||||
|
FAYDA_SESSION_TTL_MINUTES=10
|
||||||
|
|
||||||
GITHUB_PACKAGE_TOKEN=
|
GITHUB_PACKAGE_TOKEN=
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
|
"jose": "^5.10.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[faydaSub]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "passenger"."BookingSeat" ADD COLUMN "faydaSub" TEXT,
|
||||||
|
ADD COLUMN "faydaVerifiedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "faydaVerifiedName" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "passenger"."User" ADD COLUMN "faydaSub" TEXT,
|
||||||
|
ADD COLUMN "faydaVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "faydaVerifiedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "passenger"."FaydaVerificationSession" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"state" TEXT NOT NULL,
|
||||||
|
"codeVerifier" TEXT NOT NULL,
|
||||||
|
"purpose" TEXT NOT NULL DEFAULT 'PURCHASE',
|
||||||
|
"saveToAccount" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||||
|
"errorCode" TEXT,
|
||||||
|
"errorDescription" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"completedAt" TIMESTAMP(3),
|
||||||
|
"userId" TEXT,
|
||||||
|
"bookingId" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "FaydaVerificationSession_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "FaydaVerificationSession_state_key" ON "passenger"."FaydaVerificationSession"("state");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "FaydaVerificationSession_userId_idx" ON "passenger"."FaydaVerificationSession"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "FaydaVerificationSession_bookingId_idx" ON "passenger"."FaydaVerificationSession"("bookingId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "FaydaVerificationSession_state_idx" ON "passenger"."FaydaVerificationSession"("state");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "FaydaVerificationSession_expiresAt_idx" ON "passenger"."FaydaVerificationSession"("expiresAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_faydaSub_key" ON "passenger"."User"("faydaSub");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "passenger"."FaydaVerificationSession" ADD CONSTRAINT "FaydaVerificationSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "passenger"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "FaydaVerificationSession" ADD COLUMN "authCode" TEXT,
|
||||||
|
ADD COLUMN "platform" TEXT NOT NULL DEFAULT 'WEB';
|
||||||
@@ -224,6 +224,11 @@ model User {
|
|||||||
lastLoginAt DateTime?
|
lastLoginAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
faydaVerified Boolean @default(false)
|
||||||
|
faydaVerifiedAt DateTime?
|
||||||
|
faydaSub String? @unique
|
||||||
|
|
||||||
passenger Passenger?
|
passenger Passenger?
|
||||||
agent Agent?
|
agent Agent?
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
@@ -232,6 +237,8 @@ model User {
|
|||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
fraudAlerts FraudAlert[]
|
fraudAlerts FraudAlert[]
|
||||||
|
|
||||||
|
faydaVerificationSessions FaydaVerificationSession[]
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,6 +522,9 @@ model BookingSeat {
|
|||||||
passportCountry String?
|
passportCountry String?
|
||||||
verifaydaVerified Boolean @default(false)
|
verifaydaVerified Boolean @default(false)
|
||||||
verifaydaData Json?
|
verifaydaData Json?
|
||||||
|
faydaVerifiedAt DateTime?
|
||||||
|
faydaSub String?
|
||||||
|
faydaVerifiedName String?
|
||||||
seatLabelSnapshot String?
|
seatLabelSnapshot String?
|
||||||
fareMinor Int?
|
fareMinor Int?
|
||||||
displayCurrency Currency?
|
displayCurrency Currency?
|
||||||
@@ -1256,3 +1266,32 @@ model SavedPassengerProfile {
|
|||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model FaydaVerificationSession {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
state String @unique
|
||||||
|
codeVerifier String
|
||||||
|
purpose String @default("PURCHASE")
|
||||||
|
platform String @default("WEB") // WEB | MOBILE — recorded for audit
|
||||||
|
saveToAccount Boolean @default(false)
|
||||||
|
status String @default("PENDING")
|
||||||
|
errorCode String?
|
||||||
|
errorDescription String?
|
||||||
|
authCode String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
expiresAt DateTime
|
||||||
|
completedAt DateTime?
|
||||||
|
|
||||||
|
userId String?
|
||||||
|
bookingId String?
|
||||||
|
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([bookingId])
|
||||||
|
@@index([state])
|
||||||
|
@@index([expiresAt])
|
||||||
|
|
||||||
|
@@schema("passenger")
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import cbeConfig from './config/cbe.config';
|
|||||||
import ebirrConfig from './config/ebirr.config';
|
import ebirrConfig from './config/ebirr.config';
|
||||||
import cardConfig from './config/card.config';
|
import cardConfig from './config/card.config';
|
||||||
import waafiConfig from './config/waafi.config';
|
import waafiConfig from './config/waafi.config';
|
||||||
|
import faydaConfig from './config/fayda.config';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
import { StationsModule } from './modules/stations/stations.module';
|
import { StationsModule } from './modules/stations/stations.module';
|
||||||
import { FleetModule } from './modules/fleet/fleet.module';
|
import { FleetModule } from './modules/fleet/fleet.module';
|
||||||
@@ -36,12 +37,22 @@ import { ReportsModule } from './modules/reports/reports.module';
|
|||||||
import { FraudModule } from './modules/fraud/fraud.module';
|
import { FraudModule } from './modules/fraud/fraud.module';
|
||||||
import { SeatClassesModule } from './modules/seat-classes/seat-classes.module';
|
import { SeatClassesModule } from './modules/seat-classes/seat-classes.module';
|
||||||
import { FareEngineModule } from './modules/fare-engine/fare-engine.module';
|
import { FareEngineModule } from './modules/fare-engine/fare-engine.module';
|
||||||
|
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
load: [appConfig, dbConfig, telebirrConfig, cbeConfig, ebirrConfig, cardConfig, waafiConfig],
|
load: [
|
||||||
|
appConfig,
|
||||||
|
dbConfig,
|
||||||
|
telebirrConfig,
|
||||||
|
cbeConfig,
|
||||||
|
ebirrConfig,
|
||||||
|
cardConfig,
|
||||||
|
waafiConfig,
|
||||||
|
faydaConfig,
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
EventEmitterModule.forRoot(),
|
EventEmitterModule.forRoot(),
|
||||||
@@ -71,6 +82,7 @@ import { FareEngineModule } from './modules/fare-engine/fare-engine.module';
|
|||||||
FraudModule,
|
FraudModule,
|
||||||
SeatClassesModule,
|
SeatClassesModule,
|
||||||
FareEngineModule,
|
FareEngineModule,
|
||||||
|
VerifaydaModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
|
|||||||
118
apps/edr-passenger-api/src/config/fayda.config.ts
Normal file
118
apps/edr-passenger-api/src/config/fayda.config.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
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;
|
||||||
|
redirectUri: 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';
|
||||||
|
const scope = process.env.FAYDA_SCOPE ?? 'openid profile email';
|
||||||
|
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 ?? '';
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
|
||||||
|
scope,
|
||||||
|
acrValues,
|
||||||
|
claimsLocales,
|
||||||
|
sessionTtlMinutes: sessionTtl,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like {@link JwtGuard}, but never rejects the request.
|
||||||
|
*
|
||||||
|
* When a valid `Authorization: Bearer <jwt>` is present, `request.user` is
|
||||||
|
* populated from the JWT strategy (`{ userId, ... }`). When the token is
|
||||||
|
* missing or invalid, the request still proceeds with `request.user`
|
||||||
|
* undefined — the handler decides what to do.
|
||||||
|
*
|
||||||
|
* Used on `POST /fayda/verification/start`, which must work for both
|
||||||
|
* logged-in users (who can opt to save the verification to their account)
|
||||||
|
* and guests (anchored to a booking only).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class OptionalJwtGuard extends AuthGuard('jwt') {
|
||||||
|
handleRequest<TUser = unknown>(_err: unknown, user: TUser): TUser {
|
||||||
|
return (user ?? null) as TUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,111 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
|
import { OptionalJwtGuard } from './optional-jwt.guard';
|
||||||
|
import {
|
||||||
|
CompleteVerificationResultDto,
|
||||||
|
StartVerificationDto,
|
||||||
|
VerifaydaCallbackDto,
|
||||||
|
VerificationStatusDto,
|
||||||
|
} from './verifayda.dto';
|
||||||
|
import { VerifaydaService } from './verifayda.service';
|
||||||
|
|
||||||
|
/** Shape the JWT strategy puts on `request.user` (see common/jwt.strategy.ts). */
|
||||||
|
interface AuthedUser {
|
||||||
|
userId: string;
|
||||||
|
email?: string;
|
||||||
|
role?: string;
|
||||||
|
passengerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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?: AuthedUser;
|
||||||
|
}
|
||||||
|
interface RequestWithUser {
|
||||||
|
user: AuthedUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Fayda Verification')
|
||||||
|
@Controller('fayda/verification')
|
||||||
|
export class VerifaydaController {
|
||||||
|
constructor(private readonly service: VerifaydaService) {}
|
||||||
|
|
||||||
|
@Post('start')
|
||||||
|
@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; when \`saveToAccount\` is true their account is marked verified on success.
|
||||||
|
- For a **PURCHASE** flow, pass \`bookingId\` to stamp the booking's seats as Fayda-verified.
|
||||||
|
- 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 ?? 'PURCHASE',
|
||||||
|
platform: dto.platform ?? 'WEB',
|
||||||
|
userId: req.user?.userId,
|
||||||
|
bookingId: dto.bookingId,
|
||||||
|
saveToAccount: dto.saveToAccount,
|
||||||
|
});
|
||||||
|
return { authorizationUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('complete')
|
||||||
|
@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.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class StartVerificationDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: ['LOGIN', 'PURCHASE'],
|
||||||
|
default: 'PURCHASE',
|
||||||
|
description: 'Reason for verification.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['LOGIN', 'PURCHASE'])
|
||||||
|
purpose?: 'LOGIN' | 'PURCHASE';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Booking the verification should attach to (PURCHASE flow). If omitted, the session is anchored only to the user.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
bookingId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'When true and the user is logged in, copy faydaVerified=true / faydaSub onto their User record after verification.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
saveToAccount?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: ['WEB', 'MOBILE'],
|
||||||
|
default: 'WEB',
|
||||||
|
description:
|
||||||
|
'Client platform. Decides where /callback redirects on completion: a web https URL (WEB) or a custom-scheme deep link the Flutter app intercepts (MOBILE).',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['WEB', 'MOBILE'])
|
||||||
|
platform?: 'WEB' | 'MOBILE';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CompleteVerificationResultDto {
|
||||||
|
@ApiProperty({ enum: ['LOGIN', 'PURCHASE'] })
|
||||||
|
purpose: 'LOGIN' | 'PURCHASE';
|
||||||
|
|
||||||
|
@ApiProperty() verified: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'JWT (LOGIN flow only).' })
|
||||||
|
token?: 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 (PURCHASE flow).',
|
||||||
|
})
|
||||||
|
fullName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { VerifaydaController } from './verifayda.controller';
|
||||||
import { VerifaydaService } from './verifayda.service';
|
import { VerifaydaService } from './verifayda.service';
|
||||||
import { PrismaModule } from '../../common/prisma.module';
|
import { PrismaModule } from '../../common/prisma.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
// AuthModule re-exports JwtModule, giving us JwtService (same secret/expiry
|
||||||
|
// config as /auth/login) to mint tokens for the LOGIN flow.
|
||||||
|
imports: [PrismaModule, AuthModule],
|
||||||
|
controllers: [VerifaydaController],
|
||||||
providers: [VerifaydaService],
|
providers: [VerifaydaService],
|
||||||
exports: [VerifaydaService],
|
exports: [VerifaydaService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,569 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { exportJWK, generateKeyPair, type JWK } from 'jose';
|
||||||
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
|
import { FaydaConfig } from '../../config/fayda.config';
|
||||||
|
import { VerifaydaService } from './verifayda.service';
|
||||||
|
|
||||||
|
function buildPrismaMock() {
|
||||||
|
return {
|
||||||
|
faydaVerificationSession: {
|
||||||
|
create: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
updateMany: jest.fn(),
|
||||||
|
},
|
||||||
|
bookingSeat: {
|
||||||
|
updateMany: jest.fn(),
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
findFirst: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
passenger: { create: jest.fn() },
|
||||||
|
loyaltyAccount: { create: jest.fn() },
|
||||||
|
walletAccount: { create: jest.fn() },
|
||||||
|
userPreferences: { create: jest.fn() },
|
||||||
|
verifaydaVerification: { create: jest.fn() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildJwtMock(): jest.Mocked<JwtService> {
|
||||||
|
return {
|
||||||
|
sign: jest.fn(() => 'signed.jwt.token'),
|
||||||
|
} as unknown as jest.Mocked<JwtService>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfig(overrides?: Partial<FaydaConfig>): FaydaConfig {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
clientId: 'edr-test-client',
|
||||||
|
authorizationEndpoint: 'https://esignet.test/authorize',
|
||||||
|
tokenEndpoint: 'https://esignet.test/token',
|
||||||
|
userInfoEndpoint: 'https://esignet.test/userinfo',
|
||||||
|
redirectUri: 'http://localhost:4000/fayda/verification/complete',
|
||||||
|
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
|
||||||
|
scope: 'openid profile email',
|
||||||
|
acrValues: 'mosip:idp:acr:generated-code',
|
||||||
|
claimsLocales: 'en am',
|
||||||
|
sessionTtlMinutes: 10,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfigService(faydaConfig: FaydaConfig): jest.Mocked<ConfigService> {
|
||||||
|
return {
|
||||||
|
get: jest.fn((key: string, defaultValue?: unknown) => {
|
||||||
|
if (key === 'fayda') return faydaConfig;
|
||||||
|
if (key === 'VERIFAYDA_ENABLED') return false;
|
||||||
|
return defaultValue;
|
||||||
|
}),
|
||||||
|
} as unknown as jest.Mocked<ConfigService>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||||
|
let prisma: ReturnType<typeof buildPrismaMock>;
|
||||||
|
let jwt: jest.Mocked<JwtService>;
|
||||||
|
let service: VerifaydaService;
|
||||||
|
let realPrivateJwk: JWK;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const kp = await generateKeyPair('RS256', { extractable: true });
|
||||||
|
realPrivateJwk = await exportJWK(kp.privateKey);
|
||||||
|
realPrivateJwk.kty = 'RSA';
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = buildPrismaMock();
|
||||||
|
jwt = buildJwtMock();
|
||||||
|
const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] });
|
||||||
|
service = new VerifaydaService(
|
||||||
|
buildConfigService(cfg),
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
jwt,
|
||||||
|
);
|
||||||
|
(global as any).fetch = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('startVerification', () => {
|
||||||
|
it('persists a session and returns a fully-formed authorize URL', async () => {
|
||||||
|
prisma.faydaVerificationSession.create.mockResolvedValue({});
|
||||||
|
|
||||||
|
const url = await service.startVerification({
|
||||||
|
purpose: 'PURCHASE',
|
||||||
|
userId: 'user-1',
|
||||||
|
saveToAccount: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data;
|
||||||
|
expect(created.purpose).toBe('PURCHASE');
|
||||||
|
expect(created.platform).toBe('WEB');
|
||||||
|
expect(typeof created.state).toBe('string');
|
||||||
|
expect(typeof created.codeVerifier).toBe('string');
|
||||||
|
|
||||||
|
const parsed = new URL(url);
|
||||||
|
expect(parsed.origin + parsed.pathname).toBe('https://esignet.test/authorize');
|
||||||
|
expect(parsed.searchParams.get('client_id')).toBe('edr-test-client');
|
||||||
|
expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
|
||||||
|
expect(parsed.searchParams.get('redirect_uri')).toBe(
|
||||||
|
'http://localhost:4000/fayda/verification/complete',
|
||||||
|
);
|
||||||
|
expect(parsed.searchParams.get('state')).toBe(created.state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the same single redirect_uri regardless of platform (platform is only recorded)', async () => {
|
||||||
|
prisma.faydaVerificationSession.create.mockResolvedValue({});
|
||||||
|
|
||||||
|
const url = await service.startVerification({
|
||||||
|
purpose: 'LOGIN',
|
||||||
|
platform: 'MOBILE',
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data;
|
||||||
|
expect(created.platform).toBe('MOBILE');
|
||||||
|
expect(new URL(url).searchParams.get('redirect_uri')).toBe(
|
||||||
|
'http://localhost:4000/fayda/verification/complete',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ServiceUnavailable when fayda integration is disabled', async () => {
|
||||||
|
const disabledService = new VerifaydaService(
|
||||||
|
buildConfigService(buildConfig({ enabled: false })),
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
jwt,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
disabledService.startVerification({ purpose: 'PURCHASE' }),
|
||||||
|
).rejects.toMatchObject({ status: 503 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('completeVerification — validation', () => {
|
||||||
|
function pendingSession(overrides: Partial<any> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'session-1',
|
||||||
|
state: 'state-abc',
|
||||||
|
codeVerifier: 'verifier-xyz',
|
||||||
|
purpose: 'PURCHASE',
|
||||||
|
platform: 'WEB',
|
||||||
|
saveToAccount: false,
|
||||||
|
status: 'PENDING',
|
||||||
|
errorCode: null,
|
||||||
|
errorDescription: null,
|
||||||
|
userId: null,
|
||||||
|
bookingId: null,
|
||||||
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('throws and marks failed when callback carries an error', async () => {
|
||||||
|
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
await expect(
|
||||||
|
service.completeVerification({
|
||||||
|
error: 'access_denied',
|
||||||
|
error_description: 'user cancelled',
|
||||||
|
state: 'state-abc',
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
expect(prisma.faydaVerificationSession.updateMany).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws FAYDA_MISSING_PARAMETERS when code/state absent', async () => {
|
||||||
|
await expect(service.completeVerification({})).rejects.toMatchObject({
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws FAYDA_INVALID_STATE for unknown state', async () => {
|
||||||
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
service.completeVerification({ code: 'c', state: 'bogus' }),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws FAYDA_INVALID_STATE for a non-pending session', async () => {
|
||||||
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
||||||
|
pendingSession({ status: 'COMPLETED' }),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
service.completeVerification({ code: 'c', state: 'state-abc' }),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws FAYDA_SESSION_EXPIRED and marks failed for an expired session', async () => {
|
||||||
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
||||||
|
pendingSession({ expiresAt: new Date(Date.now() - 1000) }),
|
||||||
|
);
|
||||||
|
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
await expect(
|
||||||
|
service.completeVerification({ code: 'c', state: 'state-abc' }),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
expect(prisma.faydaVerificationSession.updateMany).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('completeVerification — PURCHASE', () => {
|
||||||
|
function pendingSession(overrides: Partial<any> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'session-1',
|
||||||
|
state: 'state-abc',
|
||||||
|
codeVerifier: 'verifier-xyz',
|
||||||
|
purpose: 'PURCHASE',
|
||||||
|
platform: 'WEB',
|
||||||
|
saveToAccount: false,
|
||||||
|
status: 'PENDING',
|
||||||
|
userId: null,
|
||||||
|
bookingId: null,
|
||||||
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockFetchSequence(...responses: Array<Partial<Response>>) {
|
||||||
|
const queue = responses.map((r) => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: async () => '',
|
||||||
|
json: async () => ({}),
|
||||||
|
headers: new Headers({ 'content-type': 'application/json' }),
|
||||||
|
...r,
|
||||||
|
}));
|
||||||
|
(global as any).fetch = jest.fn(() => Promise.resolve(queue.shift()));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('stamps the booking seats and returns { verified, fullName }', async () => {
|
||||||
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
||||||
|
pendingSession({ bookingId: 'booking-1' }),
|
||||||
|
);
|
||||||
|
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
||||||
|
prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
|
||||||
|
mockFetchSequence(
|
||||||
|
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
|
||||||
|
{
|
||||||
|
headers: new Headers({ 'content-type': 'application/json' }),
|
||||||
|
text: async () =>
|
||||||
|
JSON.stringify({ sub: 'fayda-sub-1', name: 'Test User' }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.completeVerification({
|
||||||
|
code: 'authcode',
|
||||||
|
state: 'state-abc',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
purpose: 'PURCHASE',
|
||||||
|
verified: true,
|
||||||
|
fullName: 'Test User',
|
||||||
|
});
|
||||||
|
expect(result.token).toBeUndefined();
|
||||||
|
expect(prisma.bookingSeat.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { bookingId: 'booking-1' },
|
||||||
|
data: expect.objectContaining({ faydaSub: 'fayda-sub-1' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves to the User account when saveToAccount=true and no conflict', async () => {
|
||||||
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
||||||
|
pendingSession({ userId: 'user-1', saveToAccount: true }),
|
||||||
|
);
|
||||||
|
prisma.user.findFirst.mockResolvedValue(null);
|
||||||
|
prisma.user.update.mockResolvedValue({});
|
||||||
|
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
||||||
|
|
||||||
|
mockFetchSequence(
|
||||||
|
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
|
||||||
|
{
|
||||||
|
headers: new Headers({ 'content-type': 'application/json' }),
|
||||||
|
text: async () =>
|
||||||
|
JSON.stringify({ sub: 'fayda-sub-2', name: 'Test User' }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.completeVerification({
|
||||||
|
code: 'authcode',
|
||||||
|
state: 'state-abc',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.verified).toBe(true);
|
||||||
|
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'user-1' },
|
||||||
|
data: expect.objectContaining({ faydaVerified: true, faydaSub: 'fayda-sub-2' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws identity_conflict (409) when faydaSub belongs to another user', async () => {
|
||||||
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
||||||
|
pendingSession({ userId: 'user-1', saveToAccount: true }),
|
||||||
|
);
|
||||||
|
prisma.user.findFirst.mockResolvedValue({ id: 'other-user' });
|
||||||
|
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
|
||||||
|
mockFetchSequence(
|
||||||
|
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
|
||||||
|
{
|
||||||
|
headers: new Headers({ 'content-type': 'application/json' }),
|
||||||
|
text: async () =>
|
||||||
|
JSON.stringify({ sub: 'fayda-sub-3', name: 'Test User' }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.completeVerification({ code: 'authcode', state: 'state-abc' }),
|
||||||
|
).rejects.toMatchObject({ status: 409 });
|
||||||
|
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 502 when the token endpoint returns 4xx', async () => {
|
||||||
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
|
||||||
|
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
mockFetchSequence({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
text: async () => '{"error":"invalid_assertion"}',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.completeVerification({ code: 'authcode', state: 'state-abc' }),
|
||||||
|
).rejects.toMatchObject({ status: 502 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 502 when userinfo is an unsupported format', async () => {
|
||||||
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
|
||||||
|
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
mockFetchSequence(
|
||||||
|
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
|
||||||
|
{
|
||||||
|
headers: new Headers({ 'content-type': 'text/plain' }),
|
||||||
|
text: async () => 'not-a-jwt-not-a-json',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.completeVerification({ code: 'authcode', state: 'state-abc' }),
|
||||||
|
).rejects.toMatchObject({ status: 502 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to localized name (name#en) when name is missing', async () => {
|
||||||
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
||||||
|
pendingSession({ bookingId: 'booking-2' }),
|
||||||
|
);
|
||||||
|
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
||||||
|
prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
|
||||||
|
mockFetchSequence(
|
||||||
|
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
|
||||||
|
{
|
||||||
|
headers: new Headers({ 'content-type': 'application/json' }),
|
||||||
|
text: async () =>
|
||||||
|
JSON.stringify({
|
||||||
|
sub: 'fayda-sub-4',
|
||||||
|
'name#en': 'English Name',
|
||||||
|
'name#am': 'Amharic Name',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.completeVerification({
|
||||||
|
code: 'c',
|
||||||
|
state: 'state-abc',
|
||||||
|
});
|
||||||
|
expect(result.fullName).toBe('English Name');
|
||||||
|
expect(prisma.bookingSeat.updateMany.mock.calls[0][0].data.faydaVerifiedName).toBe(
|
||||||
|
'English Name',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('completeVerification — LOGIN', () => {
|
||||||
|
function loginSession(overrides: Partial<any> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'login-session',
|
||||||
|
state: 'state-login',
|
||||||
|
codeVerifier: 'verifier-xyz',
|
||||||
|
purpose: 'LOGIN',
|
||||||
|
platform: 'WEB',
|
||||||
|
saveToAccount: false,
|
||||||
|
status: 'PENDING',
|
||||||
|
userId: null,
|
||||||
|
bookingId: null,
|
||||||
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockLoginFetch(userInfo: Record<string, unknown>) {
|
||||||
|
const queue = [
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ access_token: 'tok', token_type: 'Bearer' }),
|
||||||
|
text: async () => '',
|
||||||
|
headers: new Headers({ 'content-type': 'application/json' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({}),
|
||||||
|
text: async () => JSON.stringify(userInfo),
|
||||||
|
headers: new Headers({ 'content-type': 'application/json' }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
(global as any).fetch = jest.fn(() => Promise.resolve(queue.shift()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** user.findUnique answers the faydaSub lookup and the issueLoginToken id lookup. */
|
||||||
|
function mockUserFindUnique(bySub: any, fullUser: any) {
|
||||||
|
prisma.user.findUnique.mockImplementation(async (args: any) => {
|
||||||
|
if (args?.where?.faydaSub !== undefined) return bySub;
|
||||||
|
if (args?.where?.id !== undefined) return fullUser;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(loginSession());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a new user when no match and returns { token, user }', async () => {
|
||||||
|
const fullUser = {
|
||||||
|
id: 'new-user',
|
||||||
|
email: 'new@example.com',
|
||||||
|
role: 'PASSENGER',
|
||||||
|
passenger: { id: 'p-new' },
|
||||||
|
agent: null,
|
||||||
|
};
|
||||||
|
mockUserFindUnique(null, fullUser);
|
||||||
|
prisma.user.findFirst.mockResolvedValue(null);
|
||||||
|
prisma.user.create.mockResolvedValue({ id: 'new-user' });
|
||||||
|
prisma.passenger.create.mockResolvedValue({ id: 'p-new' });
|
||||||
|
prisma.loyaltyAccount.create.mockResolvedValue({});
|
||||||
|
prisma.walletAccount.create.mockResolvedValue({});
|
||||||
|
prisma.userPreferences.create.mockResolvedValue({});
|
||||||
|
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
||||||
|
|
||||||
|
mockLoginFetch({ sub: 'login-sub-1', name: 'New Person', email: 'new@example.com' });
|
||||||
|
|
||||||
|
const result = await service.completeVerification({
|
||||||
|
code: 'c',
|
||||||
|
state: 'state-login',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
purpose: 'LOGIN',
|
||||||
|
verified: true,
|
||||||
|
token: 'signed.jwt.token',
|
||||||
|
user: { id: 'new-user', passengerId: 'p-new' },
|
||||||
|
});
|
||||||
|
expect(prisma.user.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
faydaSub: 'login-sub-1',
|
||||||
|
faydaVerified: true,
|
||||||
|
email: 'new@example.com',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(prisma.passenger.create).toHaveBeenCalled();
|
||||||
|
expect(jwt.sign).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ sub: 'new-user', passengerId: 'p-new' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs in an existing user already linked by faydaSub', async () => {
|
||||||
|
const fullUser = {
|
||||||
|
id: 'known-user',
|
||||||
|
email: 'k@example.com',
|
||||||
|
role: 'PASSENGER',
|
||||||
|
passenger: { id: 'p-k' },
|
||||||
|
agent: null,
|
||||||
|
};
|
||||||
|
mockUserFindUnique({ id: 'known-user' }, fullUser);
|
||||||
|
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
||||||
|
|
||||||
|
mockLoginFetch({ sub: 'login-sub-2', name: 'Known' });
|
||||||
|
|
||||||
|
const result = await service.completeVerification({
|
||||||
|
code: 'c',
|
||||||
|
state: 'state-login',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.user?.id).toBe('known-user');
|
||||||
|
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links Fayda to an existing account matched by email', async () => {
|
||||||
|
const fullUser = {
|
||||||
|
id: 'acc-1',
|
||||||
|
email: 'match@example.com',
|
||||||
|
role: 'PASSENGER',
|
||||||
|
passenger: { id: 'p-1' },
|
||||||
|
agent: null,
|
||||||
|
};
|
||||||
|
mockUserFindUnique(null, fullUser);
|
||||||
|
prisma.user.findFirst.mockResolvedValue({ id: 'acc-1', faydaSub: null });
|
||||||
|
prisma.user.update.mockResolvedValue({});
|
||||||
|
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
||||||
|
|
||||||
|
mockLoginFetch({ sub: 'login-sub-3', email: 'match@example.com' });
|
||||||
|
|
||||||
|
const result = await service.completeVerification({
|
||||||
|
code: 'c',
|
||||||
|
state: 'state-login',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.user?.id).toBe('acc-1');
|
||||||
|
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { id: 'acc-1' },
|
||||||
|
data: expect.objectContaining({ faydaSub: 'login-sub-3' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws identity_conflict (409) when matched account has a different faydaSub', async () => {
|
||||||
|
mockUserFindUnique(null, null);
|
||||||
|
prisma.user.findFirst.mockResolvedValue({ id: 'acc-2', faydaSub: 'someone-else' });
|
||||||
|
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
|
||||||
|
mockLoginFetch({ sub: 'login-sub-4', email: 'match@example.com' });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.completeVerification({ code: 'c', state: 'state-login' }),
|
||||||
|
).rejects.toMatchObject({ status: 409 });
|
||||||
|
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getVerificationStatus', () => {
|
||||||
|
it('returns verified=true when User row has the flag', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
faydaVerified: true,
|
||||||
|
faydaVerifiedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
fullName: 'Test User',
|
||||||
|
});
|
||||||
|
const result = await service.getVerificationStatus('user-1');
|
||||||
|
expect(result).toEqual({
|
||||||
|
verified: true,
|
||||||
|
verifiedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
fullName: 'Test User',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns verified=false when User row is missing or unverified', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(null);
|
||||||
|
const result = await service.getVerificationStatus('user-x');
|
||||||
|
expect(result).toEqual({ verified: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,35 @@
|
|||||||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import axios, { AxiosInstance } from 'axios';
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
|
import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config';
|
||||||
|
import {
|
||||||
|
generateCodeChallenge,
|
||||||
|
generateCodeVerifier,
|
||||||
|
generateState,
|
||||||
|
} from './utils/pkce.util';
|
||||||
|
import { generateClientAssertion } from './utils/client-assertion.util';
|
||||||
|
import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto';
|
||||||
|
import {
|
||||||
|
FaydaIdentityConflictException,
|
||||||
|
FaydaTokenExchangeException,
|
||||||
|
FaydaUserInfoException,
|
||||||
|
} from './verifayda.errors';
|
||||||
|
import {
|
||||||
|
FaydaTokenResponse,
|
||||||
|
FaydaUserInfo,
|
||||||
|
NormalizedFaydaUserInfo,
|
||||||
|
VerifaydaPurpose,
|
||||||
|
} from './verifayda.types';
|
||||||
|
|
||||||
export interface VerifaydaPassengerData {
|
export interface VerifaydaPassengerData {
|
||||||
fullName: string;
|
fullName: string;
|
||||||
@@ -17,38 +45,554 @@ export interface VerifaydaVerificationResult {
|
|||||||
failureReason?: string;
|
failureReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StartVerificationInput {
|
||||||
|
purpose: VerifaydaPurpose;
|
||||||
|
platform?: FaydaPlatform;
|
||||||
|
userId?: string;
|
||||||
|
bookingId?: string;
|
||||||
|
saveToAccount?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FaydaUserSummary {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
passengerId?: string;
|
||||||
|
agentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of completing a verification. `verified` is always true on success.
|
||||||
|
* LOGIN additionally returns a JWT + user; PURCHASE returns the verified name.
|
||||||
|
*/
|
||||||
|
export interface CompleteVerificationResult {
|
||||||
|
purpose: VerifaydaPurpose;
|
||||||
|
verified: boolean;
|
||||||
|
token?: string;
|
||||||
|
user?: FaydaUserSummary;
|
||||||
|
fullName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VerifaydaService {
|
export class VerifaydaService {
|
||||||
private readonly logger = new Logger(VerifaydaService.name);
|
private readonly logger = new Logger(VerifaydaService.name);
|
||||||
|
|
||||||
|
|
||||||
|
private readonly faydaConfig: FaydaConfig;
|
||||||
|
|
||||||
private readonly httpClient: AxiosInstance;
|
private readonly httpClient: AxiosInstance;
|
||||||
private readonly enabled: boolean;
|
private readonly stubEnabled: boolean;
|
||||||
private readonly apiUrl: string;
|
private readonly stubApiUrl: string;
|
||||||
private readonly apiKey: string;
|
private readonly stubApiKey: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly jwt: JwtService,
|
||||||
) {
|
) {
|
||||||
this.enabled = this.config.get<boolean>('VERIFAYDA_ENABLED', false);
|
const fayda = this.config.get<FaydaConfig>('fayda');
|
||||||
this.apiUrl = this.config.get<string>('VERIFAYDA_API_URL', 'https://api.verifayda.gov.et/v2');
|
if (!fayda) {
|
||||||
this.apiKey = this.config.get<string>('VERIFAYDA_API_KEY', '');
|
throw new Error('Fayda config namespace not registered');
|
||||||
|
}
|
||||||
|
this.faydaConfig = fayda;
|
||||||
|
|
||||||
|
this.stubEnabled = this.config.get<boolean>('VERIFAYDA_ENABLED', false);
|
||||||
|
this.stubApiUrl = this.config.get<string>(
|
||||||
|
'VERIFAYDA_API_URL',
|
||||||
|
'https://api.verifayda.gov.et/v2',
|
||||||
|
);
|
||||||
|
this.stubApiKey = this.config.get<string>('VERIFAYDA_API_KEY', '');
|
||||||
this.httpClient = axios.create({
|
this.httpClient = axios.create({
|
||||||
baseURL: this.apiUrl,
|
baseURL: this.stubApiUrl,
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json', 'X-API-Key': this.stubApiKey },
|
||||||
'Content-Type': 'application/json',
|
});
|
||||||
'X-API-Key': this.apiKey,
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// 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.prisma.faydaVerificationSession.create({
|
||||||
|
data: {
|
||||||
|
state,
|
||||||
|
codeVerifier,
|
||||||
|
purpose: input.purpose,
|
||||||
|
platform: input.platform ?? 'WEB',
|
||||||
|
saveToAccount: input.saveToAccount ?? false,
|
||||||
|
userId: input.userId ?? null,
|
||||||
|
bookingId: input.bookingId ?? null,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'} bookingId=${input.bookingId ?? 'none'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.buildAuthorizationUrl({ state, codeChallenge });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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.prisma.faydaVerificationSession.findUnique({
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
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 === 'PURCHASE') {
|
||||||
|
await this.handlePurchaseSuccess(session, normalized);
|
||||||
|
result = {
|
||||||
|
purpose: 'PURCHASE',
|
||||||
|
verified: true,
|
||||||
|
fullName: normalized.fullName,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const { userId } = await this.handleLoginSuccess(normalized);
|
||||||
|
const login = await this.issueLoginToken(userId);
|
||||||
|
result = { purpose: 'LOGIN', verified: true, ...login };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.faydaVerificationSession.update({
|
||||||
|
where: { id: session.id },
|
||||||
|
data: { 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads a user (+ relations) and mints the same JWT shape as `/auth/login`. */
|
||||||
|
private async issueLoginToken(
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ token: string; user: FaydaUserSummary }> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: { passenger: true, agent: true },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
// Should not happen — we just resolved/created this user.
|
||||||
|
throw new UnauthorizedException({
|
||||||
|
code: 'FAYDA_LOGIN_FAILED',
|
||||||
|
message: 'Could not load the verified user',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary: FaydaUserSummary = {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
passengerId: user.passenger?.id,
|
||||||
|
agentId: user.agent?.id,
|
||||||
|
};
|
||||||
|
const token = this.jwt.sign({
|
||||||
|
sub: summary.id,
|
||||||
|
email: summary.email,
|
||||||
|
role: summary.role,
|
||||||
|
passengerId: summary.passengerId,
|
||||||
|
agentId: summary.agentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Fayda login issued token for user ${user.id}`);
|
||||||
|
return { token, user: summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVerificationStatus(userId: string): Promise<VerificationStatusDto> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { faydaVerified: true, faydaVerifiedAt: true, fullName: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
verified: user?.faydaVerified ?? false,
|
||||||
|
verifiedAt: user?.faydaVerifiedAt ?? undefined,
|
||||||
|
fullName: user?.fullName ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// OIDC internals
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
private buildAuthorizationUrl(args: {
|
||||||
|
state: string;
|
||||||
|
codeChallenge: string;
|
||||||
|
}): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: this.faydaConfig.clientId,
|
||||||
|
response_type: 'code',
|
||||||
|
redirect_uri: this.faydaConfig.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,
|
||||||
|
});
|
||||||
|
|
||||||
|
const claims = {
|
||||||
|
userinfo: {
|
||||||
|
name: { essential: true },
|
||||||
|
phone_number: { essential: true },
|
||||||
|
email: { essential: false },
|
||||||
|
birthdate: { essential: true },
|
||||||
|
gender: { essential: false },
|
||||||
|
picture: { essential: false },
|
||||||
|
},
|
||||||
|
id_token: {},
|
||||||
|
};
|
||||||
|
params.set('claims', JSON.stringify(claims));
|
||||||
|
|
||||||
|
return `${this.faydaConfig.authorizationEndpoint}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async exchangeCodeForTokens(
|
||||||
|
code: string,
|
||||||
|
codeVerifier: 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: this.faydaConfig.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 {
|
||||||
|
return {
|
||||||
|
sub: raw.sub,
|
||||||
|
fullName: raw.name ?? raw['name#en'] ?? raw['name#am'],
|
||||||
|
phoneNumber:
|
||||||
|
raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone,
|
||||||
|
email: raw.email,
|
||||||
|
gender: raw.gender,
|
||||||
|
birthdate: raw.birthdate,
|
||||||
|
picture: raw.picture,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handlePurchaseSuccess(
|
||||||
|
session: {
|
||||||
|
id: string;
|
||||||
|
userId: string | null;
|
||||||
|
bookingId: string | null;
|
||||||
|
saveToAccount: boolean;
|
||||||
|
},
|
||||||
|
normalized: NormalizedFaydaUserInfo,
|
||||||
|
): Promise<void> {
|
||||||
|
if (session.bookingId) {
|
||||||
|
await this.prisma.bookingSeat.updateMany({
|
||||||
|
where: { bookingId: session.bookingId },
|
||||||
|
data: {
|
||||||
|
faydaVerifiedAt: new Date(),
|
||||||
|
faydaSub: normalized.sub,
|
||||||
|
faydaVerifiedName: normalized.fullName ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.userId && session.saveToAccount) {
|
||||||
|
const conflict = await this.prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
faydaSub: normalized.sub,
|
||||||
|
NOT: { id: session.userId },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (conflict) {
|
||||||
|
throw new FaydaIdentityConflictException();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: session.userId },
|
||||||
|
data: {
|
||||||
|
faydaVerified: true,
|
||||||
|
faydaVerifiedAt: new Date(),
|
||||||
|
faydaSub: normalized.sub,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the User for a LOGIN flow and returns its id (the caller mints the
|
||||||
|
* JWT via {@link issueLoginToken}). Resolution order:
|
||||||
|
* 1. Existing user already linked to this Fayda `sub`.
|
||||||
|
* 2. Existing account whose email/phone matches — linked to this `sub`.
|
||||||
|
* 3. Otherwise a fresh Fayda-backed account is created.
|
||||||
|
*/
|
||||||
|
private async handleLoginSuccess(
|
||||||
|
normalized: NormalizedFaydaUserInfo,
|
||||||
|
): Promise<{ userId: string }> {
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
const bySub = await this.prisma.user.findUnique({
|
||||||
|
where: { faydaSub: normalized.sub },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bySub) {
|
||||||
|
userId = bySub.id;
|
||||||
|
} else {
|
||||||
|
const matchers: Array<{ email?: string; phone?: string }> = [];
|
||||||
|
if (normalized.email) matchers.push({ email: normalized.email });
|
||||||
|
if (normalized.phoneNumber) matchers.push({ phone: normalized.phoneNumber });
|
||||||
|
|
||||||
|
const existing = matchers.length
|
||||||
|
? await this.prisma.user.findFirst({
|
||||||
|
where: { OR: matchers },
|
||||||
|
select: { id: true, faydaSub: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.faydaSub && existing.faydaSub !== normalized.sub) {
|
||||||
|
// The matched account is already tied to a different Fayda identity.
|
||||||
|
throw new FaydaIdentityConflictException();
|
||||||
|
}
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
faydaSub: normalized.sub,
|
||||||
|
faydaVerified: true,
|
||||||
|
faydaVerifiedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
userId = existing.id;
|
||||||
|
this.logger.log(`Fayda login linked existing user ${existing.id}`);
|
||||||
|
} else {
|
||||||
|
userId = await this.createFaydaUser(normalized);
|
||||||
|
this.logger.log(`Fayda login created new user ${userId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Fayda-backed User plus the same satellite rows registration makes
|
||||||
|
* (Passenger, LoyaltyAccount, WalletAccount, UserPreferences).
|
||||||
|
*
|
||||||
|
* The user has no password — `passwordHash` is set to a bcrypt of random bytes
|
||||||
|
* so password login is impossible; they authenticate only via Fayda. When
|
||||||
|
* Fayda doesn't supply an email/phone, a deterministic placeholder derived from
|
||||||
|
* the (unique) `sub` keeps the NOT NULL + unique columns satisfied.
|
||||||
|
*/
|
||||||
|
private async createFaydaUser(
|
||||||
|
normalized: NormalizedFaydaUserInfo,
|
||||||
|
): Promise<string> {
|
||||||
|
const passwordHash = await bcrypt.hash(
|
||||||
|
randomBytes(32).toString('hex'),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
const email = normalized.email ?? `fayda_${normalized.sub}@users.fayda.local`;
|
||||||
|
const phone = normalized.phoneNumber ?? `fayda:${normalized.sub}`;
|
||||||
|
const fullName = normalized.fullName ?? 'Fayda User';
|
||||||
|
|
||||||
|
const user = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
fullName,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
passwordHash,
|
||||||
|
faydaVerified: true,
|
||||||
|
faydaVerifiedAt: new Date(),
|
||||||
|
faydaSub: normalized.sub,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const passenger = await this.prisma.passenger.create({
|
||||||
|
data: { userId: user.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await this.prisma.loyaltyAccount.create({
|
||||||
|
data: { passengerId: passenger.id },
|
||||||
|
});
|
||||||
|
await this.prisma.walletAccount.create({
|
||||||
|
data: { passengerId: passenger.id },
|
||||||
|
});
|
||||||
|
await this.prisma.userPreferences.create({ data: { userId: user.id } });
|
||||||
|
|
||||||
|
return user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async markSessionFailed(
|
||||||
|
state: string,
|
||||||
|
errorCode: string,
|
||||||
|
errorDescription?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.prisma.faydaVerificationSession.updateMany({
|
||||||
|
where: { state, status: 'PENDING' },
|
||||||
|
data: {
|
||||||
|
status: 'FAILED',
|
||||||
|
errorCode,
|
||||||
|
errorDescription: errorDescription ?? null,
|
||||||
|
completedAt: new Date(),
|
||||||
|
codeVerifier: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private classifyFailureReason(err: unknown): string {
|
||||||
|
if (err instanceof FaydaIdentityConflictException) return 'identity_conflict';
|
||||||
|
if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed';
|
||||||
|
if (err instanceof FaydaUserInfoException) return 'userinfo_failed';
|
||||||
|
return 'verification_failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// DEPRECATED: legacy stub flow
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/** @deprecated Use the OIDC flow instead. Retained until cleanup. */
|
||||||
async verifyNationalId(
|
async verifyNationalId(
|
||||||
nationalId: string,
|
nationalId: string,
|
||||||
bookingId?: string,
|
bookingId?: string,
|
||||||
): Promise<VerifaydaVerificationResult> {
|
): Promise<VerifaydaVerificationResult> {
|
||||||
if (!this.enabled) {
|
if (!this.stubEnabled) {
|
||||||
this.logger.warn('Verifayda is disabled - skipping verification');
|
this.logger.warn('Verifayda stub is disabled - skipping verification');
|
||||||
return {
|
return {
|
||||||
verified: false,
|
verified: false,
|
||||||
failureReason: 'Verifayda integration is disabled',
|
failureReason: 'Verifayda integration is disabled',
|
||||||
@@ -62,10 +606,8 @@ export class VerifaydaService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.logger.log(`Verifying national ID via Verifayda 2.0`);
|
this.logger.log('Verifying national ID via legacy Verifayda stub');
|
||||||
|
|
||||||
const response = await this.httpClient.post('/verify', requestPayload);
|
const response = await this.httpClient.post('/verify', requestPayload);
|
||||||
|
|
||||||
const { data } = response;
|
const { data } = response;
|
||||||
|
|
||||||
if (data.status === 'verified' && data.citizen) {
|
if (data.status === 'verified' && data.citizen) {
|
||||||
@@ -88,36 +630,24 @@ export class VerifaydaService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log('Verifayda verification successful');
|
return { verified: true, passengerData };
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
const failureReason = data.message || 'Verification failed';
|
||||||
verified: true,
|
await this.prisma.verifaydaVerification.create({
|
||||||
passengerData,
|
data: {
|
||||||
};
|
bookingId,
|
||||||
} else {
|
nationalId,
|
||||||
const failureReason = data.message || 'Verification failed';
|
requestPayload,
|
||||||
|
responsePayload: data,
|
||||||
await this.prisma.verifaydaVerification.create({
|
|
||||||
data: {
|
|
||||||
bookingId,
|
|
||||||
nationalId,
|
|
||||||
requestPayload,
|
|
||||||
responsePayload: data,
|
|
||||||
verified: false,
|
|
||||||
failureReason,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.warn(`Verifayda verification failed: ${failureReason}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
verified: false,
|
verified: false,
|
||||||
failureReason,
|
failureReason,
|
||||||
};
|
},
|
||||||
}
|
});
|
||||||
|
return { verified: false, failureReason };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const errorMessage = error.response?.data?.message || error.message || 'Unknown error';
|
const errorMessage =
|
||||||
|
error.response?.data?.message || error.message || 'Unknown error';
|
||||||
await this.prisma.verifaydaVerification.create({
|
await this.prisma.verifaydaVerification.create({
|
||||||
data: {
|
data: {
|
||||||
bookingId,
|
bookingId,
|
||||||
@@ -127,16 +657,15 @@ export class VerifaydaService {
|
|||||||
failureReason: errorMessage,
|
failureReason: errorMessage,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.logger.error(`Verifayda stub error: ${errorMessage}`);
|
||||||
this.logger.error(`Verifayda API error: ${errorMessage}`);
|
|
||||||
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`National ID verification failed: ${errorMessage}`,
|
`National ID verification failed: ${errorMessage}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use `faydaConfig.enabled` for the OIDC flow. */
|
||||||
isEnabled(): boolean {
|
isEnabled(): boolean {
|
||||||
return this.enabled;
|
return this.stubEnabled;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export type VerifaydaPurpose = 'LOGIN' | 'PURCHASE';
|
||||||
|
|
||||||
|
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;
|
||||||
|
fullName?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
email?: string;
|
||||||
|
gender?: string;
|
||||||
|
birthdate?: string;
|
||||||
|
picture?: string;
|
||||||
|
}
|
||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -320,6 +320,9 @@ importers:
|
|||||||
class-validator:
|
class-validator:
|
||||||
specifier: ^0.14.0
|
specifier: ^0.14.0
|
||||||
version: 0.14.4
|
version: 0.14.4
|
||||||
|
jose:
|
||||||
|
specifier: ^5.10.0
|
||||||
|
version: 5.10.0
|
||||||
passport:
|
passport:
|
||||||
specifier: ^0.7.0
|
specifier: ^0.7.0
|
||||||
version: 0.7.0
|
version: 0.7.0
|
||||||
|
|||||||
Reference in New Issue
Block a user