mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: ( fayda ) implement verify with fayda
This commit is contained in:
@@ -1271,7 +1271,7 @@ model FaydaVerificationSession {
|
||||
id String @id @default(uuid())
|
||||
state String @unique
|
||||
codeVerifier String
|
||||
purpose String @default("PURCHASE")
|
||||
purpose String @default("VERIFY") // VERIFY | LOGIN
|
||||
platform String @default("WEB") // WEB | MOBILE — recorded for audit
|
||||
saveToAccount Boolean @default(false)
|
||||
status String @default("PENDING")
|
||||
|
||||
@@ -55,8 +55,9 @@ export class VerifaydaController {
|
||||
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.
|
||||
- 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({
|
||||
@@ -73,11 +74,9 @@ export class VerifaydaController {
|
||||
@Req() req: RequestWithOptionalUser,
|
||||
): Promise<{ authorizationUrl: string }> {
|
||||
const authorizationUrl = await this.service.startVerification({
|
||||
purpose: dto.purpose ?? 'PURCHASE',
|
||||
purpose: dto.purpose ?? 'VERIFY',
|
||||
platform: dto.platform ?? 'WEB',
|
||||
userId: req.user?.userId,
|
||||
bookingId: dto.bookingId,
|
||||
saveToAccount: dto.saveToAccount,
|
||||
});
|
||||
return { authorizationUrl };
|
||||
}
|
||||
|
||||
@@ -1,31 +1,16 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator';
|
||||
import { 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({
|
||||
enum: ['LOGIN', 'VERIFY'],
|
||||
default: 'VERIFY',
|
||||
description:
|
||||
'Booking the verification should attach to (PURCHASE flow). If omitted, the session is anchored only to the user.',
|
||||
'Reason for verification. VERIFY returns the verified identity attributes; LOGIN resolves/creates a user and returns a JWT.',
|
||||
})
|
||||
@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;
|
||||
@IsIn(['LOGIN', 'VERIFY'])
|
||||
purpose?: 'LOGIN' | 'VERIFY';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ['WEB', 'MOBILE'],
|
||||
@@ -39,8 +24,8 @@ export class StartVerificationDto {
|
||||
}
|
||||
|
||||
export class CompleteVerificationResultDto {
|
||||
@ApiProperty({ enum: ['LOGIN', 'PURCHASE'] })
|
||||
purpose: 'LOGIN' | 'PURCHASE';
|
||||
@ApiProperty({ enum: ['LOGIN', 'VERIFY'] })
|
||||
purpose: 'LOGIN' | 'VERIFY';
|
||||
|
||||
@ApiProperty() verified: boolean;
|
||||
|
||||
@@ -58,10 +43,22 @@ export class CompleteVerificationResultDto {
|
||||
agentId?: string;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Verified full name from Fayda (PURCHASE flow).',
|
||||
})
|
||||
@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;
|
||||
}
|
||||
|
||||
export class VerifaydaCallbackDto {
|
||||
|
||||
@@ -96,13 +96,12 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||
prisma.faydaVerificationSession.create.mockResolvedValue({});
|
||||
|
||||
const url = await service.startVerification({
|
||||
purpose: 'PURCHASE',
|
||||
purpose: 'VERIFY',
|
||||
userId: 'user-1',
|
||||
saveToAccount: true,
|
||||
});
|
||||
|
||||
const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data;
|
||||
expect(created.purpose).toBe('PURCHASE');
|
||||
expect(created.purpose).toBe('VERIFY');
|
||||
expect(created.platform).toBe('WEB');
|
||||
expect(typeof created.state).toBe('string');
|
||||
expect(typeof created.codeVerifier).toBe('string');
|
||||
@@ -139,7 +138,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||
jwt,
|
||||
);
|
||||
await expect(
|
||||
disabledService.startVerification({ purpose: 'PURCHASE' }),
|
||||
disabledService.startVerification({ purpose: 'VERIFY' }),
|
||||
).rejects.toMatchObject({ status: 503 });
|
||||
});
|
||||
});
|
||||
@@ -150,14 +149,12 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||
id: 'session-1',
|
||||
state: 'state-abc',
|
||||
codeVerifier: 'verifier-xyz',
|
||||
purpose: 'PURCHASE',
|
||||
purpose: 'VERIFY',
|
||||
platform: 'WEB',
|
||||
saveToAccount: false,
|
||||
status: 'PENDING',
|
||||
errorCode: null,
|
||||
errorDescription: null,
|
||||
userId: null,
|
||||
bookingId: null,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
...overrides,
|
||||
};
|
||||
@@ -209,18 +206,16 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('completeVerification — PURCHASE', () => {
|
||||
describe('completeVerification — VERIFY', () => {
|
||||
function pendingSession(overrides: Partial<any> = {}) {
|
||||
return {
|
||||
id: 'session-1',
|
||||
state: 'state-abc',
|
||||
codeVerifier: 'verifier-xyz',
|
||||
purpose: 'PURCHASE',
|
||||
purpose: 'VERIFY',
|
||||
platform: 'WEB',
|
||||
saveToAccount: false,
|
||||
status: 'PENDING',
|
||||
userId: null,
|
||||
bookingId: null,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
...overrides,
|
||||
};
|
||||
@@ -238,19 +233,23 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||
(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' }),
|
||||
);
|
||||
it('returns the verified identity attributes and writes no domain rows', async () => {
|
||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
|
||||
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' }),
|
||||
JSON.stringify({
|
||||
sub: 'fayda-sub-1',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
phone_number: '+251911000000',
|
||||
birthdate: '1990-05-01',
|
||||
gender: 'Male',
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -260,65 +259,17 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
purpose: 'PURCHASE',
|
||||
purpose: 'VERIFY',
|
||||
verified: true,
|
||||
fullName: 'Test User',
|
||||
email: 'test@example.com',
|
||||
phoneNumber: '+251911000000',
|
||||
birthdate: '1990-05-01',
|
||||
gender: 'Male',
|
||||
});
|
||||
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(result.user).toBeUndefined();
|
||||
expect(prisma.bookingSeat.updateMany).not.toHaveBeenCalled();
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -353,11 +304,8 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||
});
|
||||
|
||||
it('falls back to localized name (name#en) when name is missing', async () => {
|
||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
||||
pendingSession({ bookingId: 'booking-2' }),
|
||||
);
|
||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
|
||||
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
||||
prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 });
|
||||
|
||||
mockFetchSequence(
|
||||
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
|
||||
@@ -377,9 +325,6 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||
state: 'state-abc',
|
||||
});
|
||||
expect(result.fullName).toBe('English Name');
|
||||
expect(prisma.bookingSeat.updateMany.mock.calls[0][0].data.faydaVerifiedName).toBe(
|
||||
'English Name',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -391,10 +336,8 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||
codeVerifier: 'verifier-xyz',
|
||||
purpose: 'LOGIN',
|
||||
platform: 'WEB',
|
||||
saveToAccount: false,
|
||||
status: 'PENDING',
|
||||
userId: null,
|
||||
bookingId: null,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -49,8 +49,6 @@ export interface StartVerificationInput {
|
||||
purpose: VerifaydaPurpose;
|
||||
platform?: FaydaPlatform;
|
||||
userId?: string;
|
||||
bookingId?: string;
|
||||
saveToAccount?: boolean;
|
||||
}
|
||||
|
||||
export interface FaydaUserSummary {
|
||||
@@ -63,7 +61,8 @@ export interface FaydaUserSummary {
|
||||
|
||||
/**
|
||||
* Result of completing a verification. `verified` is always true on success.
|
||||
* LOGIN additionally returns a JWT + user; PURCHASE returns the verified name.
|
||||
* 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;
|
||||
@@ -71,6 +70,10 @@ export interface CompleteVerificationResult {
|
||||
token?: string;
|
||||
user?: FaydaUserSummary;
|
||||
fullName?: string;
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
birthdate?: string;
|
||||
gender?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -134,15 +137,13 @@ export class VerifaydaService {
|
||||
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'}`,
|
||||
`Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'}`,
|
||||
);
|
||||
|
||||
return this.buildAuthorizationUrl({ state, codeChallenge });
|
||||
@@ -206,17 +207,22 @@ export class VerifaydaService {
|
||||
}
|
||||
|
||||
let result: CompleteVerificationResult;
|
||||
if (session.purpose === 'PURCHASE') {
|
||||
await this.handlePurchaseSuccess(session, normalized);
|
||||
result = {
|
||||
purpose: 'PURCHASE',
|
||||
verified: true,
|
||||
fullName: normalized.fullName,
|
||||
};
|
||||
} else {
|
||||
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 and hand the verified attributes back to the
|
||||
// caller. No domain writes; the session row tracks status as usual.
|
||||
result = {
|
||||
purpose: 'VERIFY',
|
||||
verified: true,
|
||||
fullName: normalized.fullName,
|
||||
email: normalized.email,
|
||||
phoneNumber: normalized.phoneNumber,
|
||||
birthdate: normalized.birthdate,
|
||||
gender: normalized.gender,
|
||||
};
|
||||
}
|
||||
|
||||
await this.prisma.faydaVerificationSession.update({
|
||||
@@ -413,49 +419,6 @@ export class VerifaydaService {
|
||||
};
|
||||
}
|
||||
|
||||
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,4 +1,4 @@
|
||||
export type VerifaydaPurpose = 'LOGIN' | 'PURCHASE';
|
||||
export type VerifaydaPurpose = 'LOGIN' | 'VERIFY';
|
||||
|
||||
export interface FaydaTokenResponse {
|
||||
access_token: string;
|
||||
|
||||
Reference in New Issue
Block a user