mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
esignet userinfo carries no national id number. keep sub/name/email/ phone/address, remove fanClaims config and the hard-fail gate that would've blocked every real verification. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
608 lines
21 KiB
TypeScript
608 lines
21 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Injectable,
|
|
Logger,
|
|
ServiceUnavailableException,
|
|
UnauthorizedException,
|
|
} from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
|
import { DataSource, Repository } from 'typeorm';
|
|
import { generateToken, generateRefreshToken } from '@tria-plc/api-common/utils/token';
|
|
import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config';
|
|
import { FaydaVerificationSession } from './entities/fayda-verification-session.entity';
|
|
import {
|
|
generateCodeChallenge,
|
|
generateCodeVerifier,
|
|
generateState,
|
|
} from './utils/pkce.util';
|
|
import { generateClientAssertion } from './utils/client-assertion.util';
|
|
import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto';
|
|
import {
|
|
FaydaTokenExchangeException,
|
|
FaydaUserInfoException,
|
|
} from './verifayda.errors';
|
|
import {
|
|
FaydaTokenResponse,
|
|
FaydaUserInfo,
|
|
NormalizedFaydaUserInfo,
|
|
VerifaydaPurpose,
|
|
} from './verifayda.types';
|
|
|
|
export interface StartVerificationInput {
|
|
purpose: VerifaydaPurpose;
|
|
platform?: FaydaPlatform;
|
|
userId?: string; // iamUserId of the authenticated user, if any
|
|
wantsPasswordSetup?: boolean;
|
|
}
|
|
|
|
export interface FaydaUserSummary {
|
|
id: string;
|
|
email: string;
|
|
role: string;
|
|
}
|
|
|
|
/**
|
|
* Result of completing a verification. `verified` is always true on success.
|
|
* 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;
|
|
verified: boolean;
|
|
token?: string;
|
|
refreshToken?: string;
|
|
requiresPassword?: boolean;
|
|
promptPasswordSetup?: boolean;
|
|
iamUserId?: string;
|
|
user?: FaydaUserSummary;
|
|
/** Fayda OIDC subject — the stable key a verified identity is stored under. */
|
|
sub?: string;
|
|
fullName?: string;
|
|
email?: string;
|
|
phoneNumber?: string;
|
|
birthdate?: string;
|
|
gender?: string;
|
|
/** Verified address, English rendering (falls back to Amharic). */
|
|
address?: string;
|
|
userDataSaved?: boolean;
|
|
}
|
|
|
|
@Injectable()
|
|
export class VerifaydaService {
|
|
private readonly logger = new Logger(VerifaydaService.name);
|
|
|
|
private readonly faydaConfig: FaydaConfig;
|
|
|
|
constructor(
|
|
private readonly config: ConfigService,
|
|
@InjectRepository(FaydaVerificationSession)
|
|
private readonly sessionRepo: Repository<FaydaVerificationSession>,
|
|
@InjectDataSource() private readonly dataSource: DataSource,
|
|
) {
|
|
const fayda = this.config.get<FaydaConfig>('fayda');
|
|
if (!fayda) {
|
|
throw new Error('Fayda config namespace not registered');
|
|
}
|
|
this.faydaConfig = fayda;
|
|
}
|
|
|
|
// ==========================================================================
|
|
// 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.sessionRepo.save(
|
|
this.sessionRepo.create({
|
|
state,
|
|
codeVerifier,
|
|
purpose: input.purpose,
|
|
platform: input.platform ?? 'WEB',
|
|
saveToAccount: input.wantsPasswordSetup ?? false,
|
|
iamUserId: input.userId ?? null,
|
|
expiresAt,
|
|
}),
|
|
);
|
|
|
|
this.logger.log(
|
|
`Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'}`,
|
|
);
|
|
|
|
return this.buildAuthorizationUrl({
|
|
state,
|
|
codeChallenge,
|
|
redirectUri: this.redirectUriForPlatform(input.platform ?? 'WEB'),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Each client lands on its own registered redirect_uri: MOBILE on the base
|
|
* one, the customer portal on its own origin, everything else (backoffice) on
|
|
* the web one. All three must be registered with eSignet.
|
|
*/
|
|
private redirectUriForPlatform(platform?: FaydaPlatform): string {
|
|
if (platform === 'MOBILE') return this.faydaConfig.redirectUri;
|
|
if (platform === 'PORTAL') return this.faydaConfig.portalRedirectUri;
|
|
return this.faydaConfig.webRedirectUri;
|
|
}
|
|
|
|
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.sessionRepo.findOne({
|
|
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,
|
|
this.redirectUriForPlatform(session.platform as FaydaPlatform),
|
|
);
|
|
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 === 'LOGIN') {
|
|
const { userId } = await this.handleLoginSuccess(normalized);
|
|
const login = await this.issueLoginToken(userId);
|
|
result = { purpose: 'LOGIN', verified: true, ...login };
|
|
} else {
|
|
// 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,
|
|
sub: normalized.sub,
|
|
fullName: normalized.fullName,
|
|
email: normalized.email,
|
|
phoneNumber: normalized.phoneNumber,
|
|
birthdate: normalized.birthdate,
|
|
gender: normalized.gender,
|
|
address: normalized.addressEn ?? normalized.addressAm,
|
|
userDataSaved,
|
|
iamUserId: iamUserId ?? undefined,
|
|
token: sessionToken?.token,
|
|
refreshToken: sessionToken?.refreshToken,
|
|
requiresPassword: sessionToken?.requiresPassword,
|
|
promptPasswordSetup: session.saveToAccount && (sessionToken?.requiresPassword ?? false),
|
|
};
|
|
}
|
|
|
|
await this.sessionRepo.update(session.id, {
|
|
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;
|
|
}
|
|
}
|
|
|
|
private async issueLoginToken(
|
|
_userId: string,
|
|
): Promise<{ token: string; user: FaydaUserSummary }> {
|
|
throw new UnauthorizedException({
|
|
code: 'FAYDA_LOGIN_MIGRATED_TO_IAM',
|
|
message: 'Fayda login tokens are issued by the IAM package auth endpoints.',
|
|
});
|
|
}
|
|
|
|
async getVerificationStatus(iamUserId: string): Promise<VerificationStatusDto> {
|
|
const rows = await this.dataSource.query<{ verified_by: string | null; updated_at: Date | null; name: { en: string; am: string } | null }[]>(
|
|
`SELECT verified_by, updated_at, name FROM iam.users WHERE id = $1 LIMIT 1`,
|
|
[iamUserId],
|
|
);
|
|
const iam = rows[0] ?? null;
|
|
const faydaVerified = iam?.verified_by === 'fayda';
|
|
const faydaVerifiedAt = faydaVerified && iam?.updated_at ? new Date(iam.updated_at) : undefined;
|
|
const fullName = iam?.name?.en ?? iam?.name?.am ?? undefined;
|
|
return { verified: faydaVerified, verifiedAt: faydaVerifiedAt, fullName };
|
|
}
|
|
|
|
// ==========================================================================
|
|
// OIDC internals
|
|
// ==========================================================================
|
|
|
|
private buildAuthorizationUrl(args: {
|
|
state: string;
|
|
codeChallenge: string;
|
|
redirectUri: string;
|
|
}): string {
|
|
const params = new URLSearchParams({
|
|
client_id: this.faydaConfig.clientId,
|
|
response_type: 'code',
|
|
redirect_uri: args.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,
|
|
});
|
|
|
|
// 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: true },
|
|
birthdate: { essential: true },
|
|
gender: { essential: true },
|
|
address: { essential: true },
|
|
nationality: { essential: true },
|
|
picture: { essential: true },
|
|
},
|
|
id_token: {},
|
|
};
|
|
params.set('claims', JSON.stringify(claims));
|
|
|
|
return `${this.faydaConfig.authorizationEndpoint}?${params.toString()}`;
|
|
}
|
|
|
|
private async exchangeCodeForTokens(
|
|
code: string,
|
|
codeVerifier: string,
|
|
redirectUri: 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: 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 {
|
|
const nameEn = raw['name#en'] as string | undefined;
|
|
const nameAm = raw['name#am'] as string | undefined;
|
|
const genderEn = raw['gender#en'] as string | undefined;
|
|
const genderAm = raw['gender#am'] as string | undefined;
|
|
const addressEn = raw['address#en'] as string | undefined;
|
|
const addressAm = raw['address#am'] as string | undefined;
|
|
const rawPhone = (raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone) as string | undefined;
|
|
|
|
return {
|
|
sub: raw.sub,
|
|
fullName: (raw.name as string | undefined) ?? nameEn ?? nameAm,
|
|
phoneNumber: rawPhone ? this.standardizePhoneNumber(rawPhone) : undefined,
|
|
rawPhoneNumber: rawPhone,
|
|
email: raw.email as string | undefined,
|
|
gender: genderEn ?? genderAm ?? (raw.gender as string | undefined),
|
|
birthdate: raw.birthdate as string | undefined,
|
|
picture: raw.picture as string | undefined,
|
|
nameEn,
|
|
nameAm,
|
|
genderEn,
|
|
genderAm,
|
|
addressEn,
|
|
addressAm,
|
|
};
|
|
}
|
|
|
|
private standardizePhoneNumber(phone: string): string {
|
|
const digits = phone.replace(/\D/g, '');
|
|
if (digits.startsWith('251')) return `+${digits}`;
|
|
if (digits.startsWith('0')) return `+251${digits.slice(1)}`;
|
|
return `+${digits}`;
|
|
}
|
|
|
|
// LOGIN via Fayda is 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.
|
|
private async handleLoginSuccess(
|
|
_normalized: NormalizedFaydaUserInfo,
|
|
): Promise<{ userId: string }> {
|
|
throw new UnauthorizedException({
|
|
code: 'FAYDA_LOGIN_MIGRATED_TO_IAM',
|
|
message: 'Fayda login tokens are issued by the IAM package at /v1/auth/fayda endpoints.',
|
|
});
|
|
}
|
|
|
|
private async upsertIamUser(
|
|
normalized: NormalizedFaydaUserInfo,
|
|
): Promise<{ iamUserId: string | null; userDataSaved: boolean }> {
|
|
try {
|
|
const iamMetadata = {
|
|
sub: normalized.sub,
|
|
address: { am: normalized.addressAm ?? '', en: normalized.addressEn ?? '' },
|
|
email: normalized.email ?? '',
|
|
gender: { am: normalized.genderAm ?? '', en: normalized.genderEn ?? '' },
|
|
name: { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' },
|
|
phoneNumber: normalized.rawPhoneNumber ?? '',
|
|
};
|
|
|
|
// 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 };
|
|
}
|
|
|
|
// Step 2 — existing user by phone or email, not yet Fayda-verified
|
|
const conditions: string[] = [];
|
|
const params: unknown[] = [];
|
|
if (normalized.phoneNumber) {
|
|
params.push(normalized.phoneNumber);
|
|
conditions.push(`phone_number = $${params.length}`);
|
|
}
|
|
if (normalized.email) {
|
|
params.push(normalized.email);
|
|
conditions.push(`email = $${params.length}`);
|
|
}
|
|
if (conditions.length > 0) {
|
|
const byContact = await this.dataSource.query<{ id: string }[]>(
|
|
`SELECT id FROM iam.users WHERE ${conditions.join(' OR ')} LIMIT 1`,
|
|
params,
|
|
);
|
|
if (byContact.length > 0) {
|
|
const existingId = byContact[0].id;
|
|
await this.dataSource.query(
|
|
`UPDATE iam.users
|
|
SET metadata = COALESCE(metadata, '{}'::jsonb) || $1::jsonb,
|
|
verified_by = 'fayda',
|
|
updated_at = NOW()
|
|
WHERE id = $2`,
|
|
[JSON.stringify(iamMetadata), existingId],
|
|
);
|
|
return { iamUserId: existingId, userDataSaved: true };
|
|
}
|
|
}
|
|
|
|
// Step 3 — new user
|
|
const name = { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' };
|
|
const username = normalized.phoneNumber ?? normalized.email ?? normalized.sub;
|
|
const inserted = await this.dataSource.query<{ id: string }[]>(
|
|
`INSERT INTO iam.users (
|
|
id, name, username, email, phone_number, metadata,
|
|
user_type, status, is_active, has_set_password,
|
|
is_phone_number_verified, verified_by,
|
|
created_at, updated_at
|
|
) VALUES (
|
|
gen_random_uuid(), $1::jsonb, $2, $3, $4, $5::jsonb,
|
|
'individual', 'submitted', true, false,
|
|
false, 'fayda',
|
|
NOW(), NOW()
|
|
) RETURNING id`,
|
|
[
|
|
JSON.stringify(name),
|
|
username,
|
|
normalized.email ?? null,
|
|
normalized.phoneNumber ?? null,
|
|
JSON.stringify(iamMetadata),
|
|
],
|
|
);
|
|
return { iamUserId: inserted[0].id, userDataSaved: true };
|
|
} catch (err) {
|
|
this.logger.error(`Fayda IAM upsert failed: ${(err as Error).message}`);
|
|
return { iamUserId: null, userDataSaved: false };
|
|
}
|
|
}
|
|
|
|
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,
|
|
errorDescription?: string,
|
|
): Promise<void> {
|
|
await this.sessionRepo.update(
|
|
{ state, status: 'PENDING' },
|
|
{
|
|
status: 'FAILED',
|
|
errorCode,
|
|
errorDescription: errorDescription ?? null,
|
|
completedAt: new Date(),
|
|
codeVerifier: '',
|
|
},
|
|
);
|
|
}
|
|
|
|
private classifyFailureReason(err: unknown): string {
|
|
if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed';
|
|
if (err instanceof FaydaUserInfoException) return 'userinfo_failed';
|
|
return 'verification_failed';
|
|
}
|
|
}
|