Merge branch 'alpha' into passenger/feat/iam

This commit is contained in:
Abubeker Yasin
2026-06-23 09:35:50 +03:00
7 changed files with 75 additions and 179 deletions

View File

@@ -1314,7 +1314,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")

View File

@@ -48,8 +48,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({
@@ -66,11 +67,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?.id,
bookingId: dto.bookingId,
saveToAccount: dto.saveToAccount,
});
return { authorizationUrl };
}

View File

@@ -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 {

View File

@@ -87,13 +87,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');
@@ -130,7 +129,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
buildDataSourceMock() as any,
);
await expect(
disabledService.startVerification({ purpose: 'PURCHASE' }),
disabledService.startVerification({ purpose: 'VERIFY' }),
).rejects.toMatchObject({ status: 503 });
});
});
@@ -141,14 +140,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,
iamUserId: null,
bookingId: null,
expiresAt: new Date(Date.now() + 60_000),
...overrides,
};
@@ -200,18 +197,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',
iamUserId: null,
bookingId: null,
expiresAt: new Date(Date.now() + 60_000),
...overrides,
};
@@ -229,19 +224,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',
}),
},
);
@@ -251,77 +250,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 IAM user account when saveToAccount=true and no conflict', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ iamUserId: 'iam-user-1', saveToAccount: true }),
);
// First dataSource.query = conflict check returns [] (no conflict)
// Second dataSource.query = UPDATE call returns []
dataSource.query
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
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);
// conflict check: SELECT id FROM iam.users WHERE metadata->>'faydaSub' = ...
expect(dataSource.query).toHaveBeenCalledWith(
expect.stringContaining(`metadata->>'faydaSub'`),
['fayda-sub-2', 'iam-user-1'],
);
// update: SET metadata = COALESCE(metadata, '{}') || ...
expect(dataSource.query).toHaveBeenCalledWith(
expect.stringContaining('UPDATE iam.users SET metadata'),
expect.arrayContaining(['iam-user-1']),
);
});
it('throws identity_conflict (409) when faydaSub belongs to another IAM user', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ iamUserId: 'iam-user-1', saveToAccount: true }),
);
// conflict check returns a conflicting row
dataSource.query.mockResolvedValueOnce([{ id: 'other-iam-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 });
// UPDATE must not have been called
expect(dataSource.query).toHaveBeenCalledTimes(1);
expect(result.user).toBeUndefined();
expect(prisma.bookingSeat.updateMany).not.toHaveBeenCalled();
});
it('throws 502 when the token endpoint returns 4xx', async () => {
@@ -355,11 +294,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' }) },
@@ -379,9 +315,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',
);
});
});
@@ -393,10 +326,8 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
codeVerifier: 'verifier-xyz',
purpose: 'LOGIN',
platform: 'WEB',
saveToAccount: false,
status: 'PENDING',
iamUserId: null,
bookingId: null,
expiresAt: new Date(Date.now() + 60_000),
...overrides,
};

View File

@@ -19,7 +19,6 @@ import {
import { generateClientAssertion } from './utils/client-assertion.util';
import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto';
import {
FaydaIdentityConflictException,
FaydaTokenExchangeException,
FaydaUserInfoException,
} from './verifayda.errors';
@@ -47,9 +46,7 @@ export interface VerifaydaVerificationResult {
export interface StartVerificationInput {
purpose: VerifaydaPurpose;
platform?: FaydaPlatform;
userId?: string;
bookingId?: string;
saveToAccount?: boolean;
userId?: string; // iamUserId of the authenticated user, if any
}
export interface FaydaUserSummary {
@@ -62,7 +59,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;
@@ -70,6 +68,10 @@ export interface CompleteVerificationResult {
token?: string;
user?: FaydaUserSummary;
fullName?: string;
email?: string;
phoneNumber?: string;
birthdate?: string;
gender?: string;
}
@Injectable()
@@ -142,15 +144,13 @@ export class VerifaydaService {
codeVerifier,
purpose: input.purpose,
platform: input.platform ?? 'WEB',
saveToAccount: input.saveToAccount ?? false,
iamUserId: 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 });
@@ -214,17 +214,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({
@@ -394,41 +399,6 @@ export class VerifaydaService {
};
}
private async handlePurchaseSuccess(
session: {
id: string;
iamUserId: 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,
},
});
}
const iamUserId = session.iamUserId;
if (iamUserId && session.saveToAccount) {
const conflicts = await this.dataSource.query<{ id: string }[]>(
`SELECT id FROM iam.users WHERE metadata->>'faydaSub' = $1 AND id != $2 LIMIT 1`,
[normalized.sub, iamUserId],
);
if (conflicts.length) throw new FaydaIdentityConflictException();
await this.dataSource.query(
`UPDATE iam.users SET metadata = COALESCE(metadata, '{}') || $1::jsonb WHERE id = $2`,
[JSON.stringify({ faydaSub: normalized.sub, faydaVerified: true, faydaVerifiedAt: new Date().toISOString() }), iamUserId],
);
}
}
// LOGIN via Fayda is now handled entirely by the IAM package's own OIDC flow.
// This method is kept as a stub so completeVerification() still compiles;
// it throws immediately without touching the database.
@@ -459,7 +429,6 @@ export class VerifaydaService {
}
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';

View File

@@ -1,4 +1,4 @@
export type VerifaydaPurpose = 'LOGIN' | 'PURCHASE';
export type VerifaydaPurpose = 'LOGIN' | 'VERIFY';
export interface FaydaTokenResponse {
access_token: string;

File diff suppressed because one or more lines are too long