mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 18:55:42 +00:00
feat(auth): add post-Fayda password setup flow
This commit is contained in:
@@ -74,6 +74,7 @@ export class VerifaydaController {
|
||||
purpose: dto.purpose ?? 'VERIFY',
|
||||
platform: dto.platform ?? 'WEB',
|
||||
userId: req.user?.id,
|
||||
wantsPasswordSetup: dto.wantsPasswordSetup ?? false,
|
||||
});
|
||||
return { authorizationUrl };
|
||||
}
|
||||
|
||||
@@ -21,6 +21,17 @@ export class StartVerificationDto {
|
||||
@IsOptional()
|
||||
@IsIn(['WEB', 'MOBILE'])
|
||||
platform?: 'WEB' | 'MOBILE';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
description:
|
||||
'Set to true when the user opts in to full account registration (checkbox). ' +
|
||||
'When true, the /complete response includes a short-lived token and promptPasswordSetup=true ' +
|
||||
'so the frontend can immediately prompt for a password via POST /v1/auth/set-fayda-password.',
|
||||
})
|
||||
@IsOptional()
|
||||
wantsPasswordSetup?: boolean;
|
||||
}
|
||||
|
||||
export class CompleteVerificationResultDto {
|
||||
@@ -29,9 +40,12 @@ export class CompleteVerificationResultDto {
|
||||
|
||||
@ApiProperty() verified: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'JWT (LOGIN flow only).' })
|
||||
@ApiPropertyOptional({ description: 'JWT. LOGIN: session token for the authenticated user. VERIFY: short-lived token for calling /v1/auth/set-fayda-password.' })
|
||||
token?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
refreshToken?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Authenticated user summary (LOGIN flow only; same shape as /auth/login).',
|
||||
})
|
||||
@@ -62,6 +76,19 @@ export class CompleteVerificationResultDto {
|
||||
|
||||
@ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' })
|
||||
userDataSaved?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'IAM user ID of the verified identity (VERIFY flow).' })
|
||||
iamUserId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'True when the IAM account has not yet set a password (VERIFY flow).' })
|
||||
requiresPassword?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'True when the user opted in to immediate password setup (wantsPasswordSetup=true at start) ' +
|
||||
'AND they have not yet set a password. Frontend should navigate to the set-password screen.',
|
||||
})
|
||||
promptPasswordSetup?: boolean;
|
||||
}
|
||||
|
||||
export class VerifaydaCallbackDto {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { generateToken, generateRefreshToken } from '@tria-plc/api-common/utils/token';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config';
|
||||
@@ -47,6 +48,7 @@ export interface StartVerificationInput {
|
||||
purpose: VerifaydaPurpose;
|
||||
platform?: FaydaPlatform;
|
||||
userId?: string; // iamUserId of the authenticated user, if any
|
||||
wantsPasswordSetup?: boolean;
|
||||
}
|
||||
|
||||
export interface FaydaUserSummary {
|
||||
@@ -66,6 +68,10 @@ export interface CompleteVerificationResult {
|
||||
purpose: VerifaydaPurpose;
|
||||
verified: boolean;
|
||||
token?: string;
|
||||
refreshToken?: string;
|
||||
requiresPassword?: boolean;
|
||||
promptPasswordSetup?: boolean;
|
||||
iamUserId?: string;
|
||||
user?: FaydaUserSummary;
|
||||
fullName?: string;
|
||||
email?: string;
|
||||
@@ -145,6 +151,7 @@ export class VerifaydaService {
|
||||
codeVerifier,
|
||||
purpose: input.purpose,
|
||||
platform: input.platform ?? 'WEB',
|
||||
saveToAccount: input.wantsPasswordSetup ?? false,
|
||||
iamUserId: input.userId ?? null,
|
||||
expiresAt,
|
||||
},
|
||||
@@ -220,8 +227,18 @@ export class VerifaydaService {
|
||||
const login = await this.issueLoginToken(userId);
|
||||
result = { purpose: 'LOGIN', verified: true, ...login };
|
||||
} else {
|
||||
// VERIFY — prove identity, save to IAM, return verified attributes.
|
||||
const { userDataSaved } = await this.upsertIamUser(normalized);
|
||||
// VERIFY — prove identity, save to IAM, return verified attributes + short-lived token.
|
||||
const { iamUserId, userDataSaved } = await this.upsertIamUser(normalized);
|
||||
|
||||
let sessionToken: { token: string; refreshToken: string; requiresPassword: boolean } | undefined;
|
||||
if (iamUserId) {
|
||||
try {
|
||||
sessionToken = await this.createFaydaSession(iamUserId);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Fayda session creation failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
result = {
|
||||
purpose: 'VERIFY',
|
||||
verified: true,
|
||||
@@ -231,6 +248,11 @@ export class VerifaydaService {
|
||||
birthdate: normalized.birthdate,
|
||||
gender: normalized.gender,
|
||||
userDataSaved,
|
||||
iamUserId: iamUserId ?? undefined,
|
||||
token: sessionToken?.token,
|
||||
refreshToken: sessionToken?.refreshToken,
|
||||
requiresPassword: sessionToken?.requiresPassword,
|
||||
promptPasswordSetup: session.saveToAccount && (sessionToken?.requiresPassword ?? false),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -298,14 +320,19 @@ export class VerifaydaService {
|
||||
claims_locales: this.faydaConfig.claimsLocales,
|
||||
});
|
||||
|
||||
// Every claim is marked essential so eSignet shows them locked/pre-checked
|
||||
// on the consent screen — the user cannot toggle any off; they either
|
||||
// consent to all of them or the whole flow is cancelled (?error=...).
|
||||
const claims = {
|
||||
userinfo: {
|
||||
name: { essential: true },
|
||||
phone_number: { essential: true },
|
||||
email: { essential: false },
|
||||
email: { essential: true },
|
||||
birthdate: { essential: true },
|
||||
gender: { essential: false },
|
||||
picture: { essential: false },
|
||||
gender: { essential: true },
|
||||
address: { essential: true },
|
||||
nationality: { essential: true },
|
||||
picture: { essential: true },
|
||||
},
|
||||
id_token: {},
|
||||
};
|
||||
@@ -447,12 +474,16 @@ export class VerifaydaService {
|
||||
phoneNumber: normalized.rawPhoneNumber ?? '',
|
||||
};
|
||||
|
||||
// Step 1 — already verified with same Fayda sub
|
||||
// Step 1 — already linked to this Fayda sub; ensure verified_by is set
|
||||
const bySub = await this.dataSource.query<{ id: string }[]>(
|
||||
`SELECT id FROM iam.users WHERE metadata->>'sub' = $1 LIMIT 1`,
|
||||
[normalized.sub],
|
||||
);
|
||||
if (bySub.length > 0) {
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.users SET verified_by = 'fayda', updated_at = NOW() WHERE id = $1`,
|
||||
[bySub[0].id],
|
||||
);
|
||||
return { iamUserId: bySub[0].id, userDataSaved: true };
|
||||
}
|
||||
|
||||
@@ -497,7 +528,7 @@ export class VerifaydaService {
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
gen_random_uuid(), $1::jsonb, $2, $3, $4, $5::jsonb,
|
||||
'individual', 'accepted', true, false,
|
||||
'individual', 'submitted', true, false,
|
||||
false, 'fayda',
|
||||
NOW(), NOW()
|
||||
) RETURNING id`,
|
||||
@@ -516,6 +547,60 @@ export class VerifaydaService {
|
||||
}
|
||||
}
|
||||
|
||||
private async createFaydaSession(
|
||||
iamUserId: string,
|
||||
): Promise<{ token: string; refreshToken: string; requiresPassword: boolean }> {
|
||||
const rows = await this.dataSource.query<{
|
||||
id: string;
|
||||
email: string;
|
||||
name: { en: string; am: string } | null;
|
||||
username: string;
|
||||
phone_number: string | null;
|
||||
has_set_password: boolean;
|
||||
status: string;
|
||||
}[]>(
|
||||
`SELECT id, email, name, username, phone_number, has_set_password, status
|
||||
FROM iam.users WHERE id = $1 LIMIT 1`,
|
||||
[iamUserId],
|
||||
);
|
||||
if (!rows.length) throw new Error(`IAM user ${iamUserId} not found`);
|
||||
const u = rows[0];
|
||||
|
||||
const userInfo = {
|
||||
id: u.id,
|
||||
email: u.email ?? '',
|
||||
name: u.name ?? { en: '', am: '' },
|
||||
userType: 'individual',
|
||||
status: u.status,
|
||||
hasSetPassword: u.has_set_password,
|
||||
isPhoneNumberVerified: false,
|
||||
hasFinishedRegistration: false,
|
||||
hasFinishedDMSOnboarding: false,
|
||||
username: u.username,
|
||||
phoneNumber: u.phone_number ?? '',
|
||||
roles: [],
|
||||
permissions: [],
|
||||
employee: [],
|
||||
};
|
||||
|
||||
const sessions = await this.dataSource.query<{ id: string }[]>(
|
||||
`INSERT INTO iam.sessions
|
||||
(id, email, device, "userInfo", expiry_time, refresh_count, status, user_id)
|
||||
VALUES (gen_random_uuid(), $1, 'fayda-verify', $2::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $3)
|
||||
ON CONFLICT (user_id, device) DO UPDATE
|
||||
SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo",
|
||||
expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW()
|
||||
RETURNING id`,
|
||||
[u.email ?? '', JSON.stringify(userInfo), iamUserId],
|
||||
);
|
||||
|
||||
const sessionId = sessions[0].id;
|
||||
const token = generateToken({ id: sessionId });
|
||||
const refreshToken = generateRefreshToken({ id: sessionId });
|
||||
|
||||
return { token, refreshToken, requiresPassword: !u.has_set_password };
|
||||
}
|
||||
|
||||
private async markSessionFailed(
|
||||
state: string,
|
||||
errorCode: string,
|
||||
|
||||
Reference in New Issue
Block a user