mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 10:45:44 +00:00
Merge branch 'alpha' into passenger/feat/iam
This commit is contained in:
@@ -1314,7 +1314,7 @@ model FaydaVerificationSession {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
state String @unique
|
state String @unique
|
||||||
codeVerifier String
|
codeVerifier String
|
||||||
purpose String @default("PURCHASE")
|
purpose String @default("VERIFY") // VERIFY | LOGIN
|
||||||
platform String @default("WEB") // WEB | MOBILE — recorded for audit
|
platform String @default("WEB") // WEB | MOBILE — recorded for audit
|
||||||
saveToAccount Boolean @default(false)
|
saveToAccount Boolean @default(false)
|
||||||
status String @default("PENDING")
|
status String @default("PENDING")
|
||||||
|
|||||||
@@ -48,8 +48,9 @@ export class VerifaydaController {
|
|||||||
summary: 'Start a VeriFayda 2.0 verification session',
|
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.
|
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.
|
- Works for **logged-in users** and **guests**. If a valid bearer token is present, the verification is tied to that user.
|
||||||
- For a **PURCHASE** flow, pass \`bookingId\` to stamp the booking's seats as Fayda-verified.
|
- **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).`,
|
- 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({
|
@ApiOkResponse({
|
||||||
@@ -66,11 +67,9 @@ export class VerifaydaController {
|
|||||||
@Req() req: RequestWithOptionalUser,
|
@Req() req: RequestWithOptionalUser,
|
||||||
): Promise<{ authorizationUrl: string }> {
|
): Promise<{ authorizationUrl: string }> {
|
||||||
const authorizationUrl = await this.service.startVerification({
|
const authorizationUrl = await this.service.startVerification({
|
||||||
purpose: dto.purpose ?? 'PURCHASE',
|
purpose: dto.purpose ?? 'VERIFY',
|
||||||
platform: dto.platform ?? 'WEB',
|
platform: dto.platform ?? 'WEB',
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
bookingId: dto.bookingId,
|
|
||||||
saveToAccount: dto.saveToAccount,
|
|
||||||
});
|
});
|
||||||
return { authorizationUrl };
|
return { authorizationUrl };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,16 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator';
|
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class StartVerificationDto {
|
export class StartVerificationDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
enum: ['LOGIN', 'PURCHASE'],
|
enum: ['LOGIN', 'VERIFY'],
|
||||||
default: 'PURCHASE',
|
default: 'VERIFY',
|
||||||
description: 'Reason for verification.',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(['LOGIN', 'PURCHASE'])
|
|
||||||
purpose?: 'LOGIN' | 'PURCHASE';
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description:
|
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()
|
@IsOptional()
|
||||||
@IsString()
|
@IsIn(['LOGIN', 'VERIFY'])
|
||||||
bookingId?: string;
|
purpose?: 'LOGIN' | 'VERIFY';
|
||||||
|
|
||||||
@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({
|
@ApiPropertyOptional({
|
||||||
enum: ['WEB', 'MOBILE'],
|
enum: ['WEB', 'MOBILE'],
|
||||||
@@ -39,8 +24,8 @@ export class StartVerificationDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CompleteVerificationResultDto {
|
export class CompleteVerificationResultDto {
|
||||||
@ApiProperty({ enum: ['LOGIN', 'PURCHASE'] })
|
@ApiProperty({ enum: ['LOGIN', 'VERIFY'] })
|
||||||
purpose: 'LOGIN' | 'PURCHASE';
|
purpose: 'LOGIN' | 'VERIFY';
|
||||||
|
|
||||||
@ApiProperty() verified: boolean;
|
@ApiProperty() verified: boolean;
|
||||||
|
|
||||||
@@ -58,10 +43,22 @@ export class CompleteVerificationResultDto {
|
|||||||
agentId?: string;
|
agentId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({ description: 'Verified full name from Fayda (VERIFY flow).' })
|
||||||
description: 'Verified full name from Fayda (PURCHASE flow).',
|
|
||||||
})
|
|
||||||
fullName?: string;
|
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 {
|
export class VerifaydaCallbackDto {
|
||||||
|
|||||||
@@ -87,13 +87,12 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
|||||||
prisma.faydaVerificationSession.create.mockResolvedValue({});
|
prisma.faydaVerificationSession.create.mockResolvedValue({});
|
||||||
|
|
||||||
const url = await service.startVerification({
|
const url = await service.startVerification({
|
||||||
purpose: 'PURCHASE',
|
purpose: 'VERIFY',
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
saveToAccount: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data;
|
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(created.platform).toBe('WEB');
|
||||||
expect(typeof created.state).toBe('string');
|
expect(typeof created.state).toBe('string');
|
||||||
expect(typeof created.codeVerifier).toBe('string');
|
expect(typeof created.codeVerifier).toBe('string');
|
||||||
@@ -130,7 +129,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
|||||||
buildDataSourceMock() as any,
|
buildDataSourceMock() as any,
|
||||||
);
|
);
|
||||||
await expect(
|
await expect(
|
||||||
disabledService.startVerification({ purpose: 'PURCHASE' }),
|
disabledService.startVerification({ purpose: 'VERIFY' }),
|
||||||
).rejects.toMatchObject({ status: 503 });
|
).rejects.toMatchObject({ status: 503 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -141,14 +140,12 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
|||||||
id: 'session-1',
|
id: 'session-1',
|
||||||
state: 'state-abc',
|
state: 'state-abc',
|
||||||
codeVerifier: 'verifier-xyz',
|
codeVerifier: 'verifier-xyz',
|
||||||
purpose: 'PURCHASE',
|
purpose: 'VERIFY',
|
||||||
platform: 'WEB',
|
platform: 'WEB',
|
||||||
saveToAccount: false,
|
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
errorCode: null,
|
errorCode: null,
|
||||||
errorDescription: null,
|
errorDescription: null,
|
||||||
iamUserId: null,
|
iamUserId: null,
|
||||||
bookingId: null,
|
|
||||||
expiresAt: new Date(Date.now() + 60_000),
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -200,18 +197,16 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('completeVerification — PURCHASE', () => {
|
describe('completeVerification — VERIFY', () => {
|
||||||
function pendingSession(overrides: Partial<any> = {}) {
|
function pendingSession(overrides: Partial<any> = {}) {
|
||||||
return {
|
return {
|
||||||
id: 'session-1',
|
id: 'session-1',
|
||||||
state: 'state-abc',
|
state: 'state-abc',
|
||||||
codeVerifier: 'verifier-xyz',
|
codeVerifier: 'verifier-xyz',
|
||||||
purpose: 'PURCHASE',
|
purpose: 'VERIFY',
|
||||||
platform: 'WEB',
|
platform: 'WEB',
|
||||||
saveToAccount: false,
|
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
iamUserId: null,
|
iamUserId: null,
|
||||||
bookingId: null,
|
|
||||||
expiresAt: new Date(Date.now() + 60_000),
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -229,19 +224,23 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
|||||||
(global as any).fetch = jest.fn(() => Promise.resolve(queue.shift()));
|
(global as any).fetch = jest.fn(() => Promise.resolve(queue.shift()));
|
||||||
}
|
}
|
||||||
|
|
||||||
it('stamps the booking seats and returns { verified, fullName }', async () => {
|
it('returns the verified identity attributes and writes no domain rows', async () => {
|
||||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
|
||||||
pendingSession({ bookingId: 'booking-1' }),
|
|
||||||
);
|
|
||||||
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
||||||
prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 });
|
|
||||||
|
|
||||||
mockFetchSequence(
|
mockFetchSequence(
|
||||||
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
|
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
|
||||||
{
|
{
|
||||||
headers: new Headers({ 'content-type': 'application/json' }),
|
headers: new Headers({ 'content-type': 'application/json' }),
|
||||||
text: async () =>
|
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({
|
expect(result).toMatchObject({
|
||||||
purpose: 'PURCHASE',
|
purpose: 'VERIFY',
|
||||||
verified: true,
|
verified: true,
|
||||||
fullName: 'Test User',
|
fullName: 'Test User',
|
||||||
|
email: 'test@example.com',
|
||||||
|
phoneNumber: '+251911000000',
|
||||||
|
birthdate: '1990-05-01',
|
||||||
|
gender: 'Male',
|
||||||
});
|
});
|
||||||
expect(result.token).toBeUndefined();
|
expect(result.token).toBeUndefined();
|
||||||
expect(prisma.bookingSeat.updateMany).toHaveBeenCalledWith({
|
expect(result.user).toBeUndefined();
|
||||||
where: { bookingId: 'booking-1' },
|
expect(prisma.bookingSeat.updateMany).not.toHaveBeenCalled();
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws 502 when the token endpoint returns 4xx', async () => {
|
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 () => {
|
it('falls back to localized name (name#en) when name is missing', async () => {
|
||||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
|
||||||
pendingSession({ bookingId: 'booking-2' }),
|
|
||||||
);
|
|
||||||
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
||||||
prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 });
|
|
||||||
|
|
||||||
mockFetchSequence(
|
mockFetchSequence(
|
||||||
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
|
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
|
||||||
@@ -379,9 +315,6 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
|||||||
state: 'state-abc',
|
state: 'state-abc',
|
||||||
});
|
});
|
||||||
expect(result.fullName).toBe('English Name');
|
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',
|
codeVerifier: 'verifier-xyz',
|
||||||
purpose: 'LOGIN',
|
purpose: 'LOGIN',
|
||||||
platform: 'WEB',
|
platform: 'WEB',
|
||||||
saveToAccount: false,
|
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
iamUserId: null,
|
iamUserId: null,
|
||||||
bookingId: null,
|
|
||||||
expiresAt: new Date(Date.now() + 60_000),
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
import { generateClientAssertion } from './utils/client-assertion.util';
|
import { generateClientAssertion } from './utils/client-assertion.util';
|
||||||
import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto';
|
import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto';
|
||||||
import {
|
import {
|
||||||
FaydaIdentityConflictException,
|
|
||||||
FaydaTokenExchangeException,
|
FaydaTokenExchangeException,
|
||||||
FaydaUserInfoException,
|
FaydaUserInfoException,
|
||||||
} from './verifayda.errors';
|
} from './verifayda.errors';
|
||||||
@@ -47,9 +46,7 @@ export interface VerifaydaVerificationResult {
|
|||||||
export interface StartVerificationInput {
|
export interface StartVerificationInput {
|
||||||
purpose: VerifaydaPurpose;
|
purpose: VerifaydaPurpose;
|
||||||
platform?: FaydaPlatform;
|
platform?: FaydaPlatform;
|
||||||
userId?: string;
|
userId?: string; // iamUserId of the authenticated user, if any
|
||||||
bookingId?: string;
|
|
||||||
saveToAccount?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FaydaUserSummary {
|
export interface FaydaUserSummary {
|
||||||
@@ -62,7 +59,8 @@ export interface FaydaUserSummary {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Result of completing a verification. `verified` is always true on success.
|
* 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 {
|
export interface CompleteVerificationResult {
|
||||||
purpose: VerifaydaPurpose;
|
purpose: VerifaydaPurpose;
|
||||||
@@ -70,6 +68,10 @@ export interface CompleteVerificationResult {
|
|||||||
token?: string;
|
token?: string;
|
||||||
user?: FaydaUserSummary;
|
user?: FaydaUserSummary;
|
||||||
fullName?: string;
|
fullName?: string;
|
||||||
|
email?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
birthdate?: string;
|
||||||
|
gender?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -142,15 +144,13 @@ export class VerifaydaService {
|
|||||||
codeVerifier,
|
codeVerifier,
|
||||||
purpose: input.purpose,
|
purpose: input.purpose,
|
||||||
platform: input.platform ?? 'WEB',
|
platform: input.platform ?? 'WEB',
|
||||||
saveToAccount: input.saveToAccount ?? false,
|
|
||||||
iamUserId: input.userId ?? null,
|
iamUserId: input.userId ?? null,
|
||||||
bookingId: input.bookingId ?? null,
|
|
||||||
expiresAt,
|
expiresAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(
|
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 });
|
return this.buildAuthorizationUrl({ state, codeChallenge });
|
||||||
@@ -214,17 +214,22 @@ export class VerifaydaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let result: CompleteVerificationResult;
|
let result: CompleteVerificationResult;
|
||||||
if (session.purpose === 'PURCHASE') {
|
if (session.purpose === 'LOGIN') {
|
||||||
await this.handlePurchaseSuccess(session, normalized);
|
|
||||||
result = {
|
|
||||||
purpose: 'PURCHASE',
|
|
||||||
verified: true,
|
|
||||||
fullName: normalized.fullName,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const { userId } = await this.handleLoginSuccess(normalized);
|
const { userId } = await this.handleLoginSuccess(normalized);
|
||||||
const login = await this.issueLoginToken(userId);
|
const login = await this.issueLoginToken(userId);
|
||||||
result = { purpose: 'LOGIN', verified: true, ...login };
|
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({
|
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.
|
// 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;
|
// This method is kept as a stub so completeVerification() still compiles;
|
||||||
// it throws immediately without touching the database.
|
// it throws immediately without touching the database.
|
||||||
@@ -459,7 +429,6 @@ export class VerifaydaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private classifyFailureReason(err: unknown): string {
|
private classifyFailureReason(err: unknown): string {
|
||||||
if (err instanceof FaydaIdentityConflictException) return 'identity_conflict';
|
|
||||||
if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed';
|
if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed';
|
||||||
if (err instanceof FaydaUserInfoException) return 'userinfo_failed';
|
if (err instanceof FaydaUserInfoException) return 'userinfo_failed';
|
||||||
return 'verification_failed';
|
return 'verification_failed';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type VerifaydaPurpose = 'LOGIN' | 'PURCHASE';
|
export type VerifaydaPurpose = 'LOGIN' | 'VERIFY';
|
||||||
|
|
||||||
export interface FaydaTokenResponse {
|
export interface FaydaTokenResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user