315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
import fetch from 'node-fetch';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { IdNotService } from '../services/idnot';
|
|
import { authTokens } from '../utils/auth-tokens';
|
|
import { IdNotUser, AuthToken } from '../types';
|
|
import { Logger } from '../utils/logger';
|
|
import { NotFoundError, ExternalServiceError, BusinessRuleError, UnauthorizedError, ForbiddenError, ValidationError } from '../types/errors';
|
|
|
|
/**
|
|
* Pure controller methods that handle business logic
|
|
* without depending on Express Request/Response objects
|
|
*/
|
|
export class IdNotController {
|
|
|
|
/**
|
|
* Get user rattachements by idNot
|
|
*/
|
|
static async getUserRattachements(idNot: string): Promise<any[]> {
|
|
Logger.info('Getting user rattachements', { idNot });
|
|
|
|
const json = await IdNotService.getUserRattachements(idNot);
|
|
|
|
// Check if any rattachements found
|
|
if (!json.result || json.result.length === 0) {
|
|
throw new NotFoundError('No rattachements found');
|
|
}
|
|
|
|
// Get office data for each rattachement
|
|
const officeData = await Promise.all(json.result.map(async (result: any) => {
|
|
const searchParams = new URLSearchParams({
|
|
key: process.env.IDNOT_API_KEY || '',
|
|
deleted: 'false'
|
|
});
|
|
|
|
try {
|
|
const response = await fetch(`${process.env.IDNOT_ANNUARY_BASE_URL}${result.entiteUrl}?` + searchParams, {
|
|
method: 'GET'
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error) {
|
|
Logger.error('Failed to fetch office data', {
|
|
entiteUrl: result.entiteUrl,
|
|
error: error instanceof Error ? error.message : 'Unknown error'
|
|
});
|
|
throw new ExternalServiceError('IdNot', `Failed to fetch office data: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
}));
|
|
|
|
Logger.info('Successfully retrieved user rattachements', {
|
|
idNot,
|
|
count: officeData.length
|
|
});
|
|
|
|
return officeData;
|
|
}
|
|
|
|
/**
|
|
* Get office rattachements by office idNot
|
|
*/
|
|
static async getOfficeRattachements(idNot: string): Promise<any> {
|
|
Logger.info('Getting office rattachements', { idNot });
|
|
|
|
try {
|
|
const result = await IdNotService.getOfficeRattachements(idNot);
|
|
|
|
Logger.info('Successfully retrieved office rattachements', {
|
|
idNot,
|
|
count: result.result?.length || 0
|
|
});
|
|
|
|
return result;
|
|
} catch (error) {
|
|
Logger.error('Failed to get office rattachements', {
|
|
idNot,
|
|
error: error instanceof Error ? error.message : 'Unknown error'
|
|
});
|
|
throw new ExternalServiceError('IdNot', `Failed to get office rattachements: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Authenticate user with authorization code
|
|
*/
|
|
static async authenticate(code: string): Promise<{ idNotUser: IdNotUser; authToken: string }> {
|
|
Logger.info('IdNot authentication initiated', { codePrefix: code.substring(0, 8) + '...' });
|
|
|
|
try {
|
|
// Exchange code for tokens
|
|
const tokens = await IdNotService.exchangeCodeForTokens(code);
|
|
|
|
const jwt = tokens.id_token;
|
|
if (!jwt) {
|
|
throw new BusinessRuleError('No ID token received from IdNot');
|
|
}
|
|
|
|
// Decode JWT payload
|
|
const payload = JSON.parse(Buffer.from(jwt.split('.') [1], 'base64').toString('utf8'));
|
|
|
|
// Log non-sensible claims for diagnostics
|
|
Logger.info('IdNot token payload summary', {
|
|
keys: Object.keys(payload || {}),
|
|
sub: payload?.sub,
|
|
entity_idn: payload?.entity_idn,
|
|
profile_idn: payload?.profile_idn
|
|
});
|
|
|
|
// Always use sub for rattachements API as it's more reliable than profile_idn
|
|
Logger.info('IdNot using rattachements API with sub', { sub: payload.sub });
|
|
const rattachementsJson = await IdNotService.getUserRattachements(payload.sub);
|
|
const results: any[] = Array.isArray(rattachementsJson?.result) ? rattachementsJson.result : [];
|
|
|
|
if (results.length === 0) {
|
|
throw new ForbiddenError('User not attached to an office');
|
|
}
|
|
|
|
// Get the first rattachement
|
|
const rattachement = results[0];
|
|
|
|
// Fetch entite and personne data separately if not present
|
|
let entiteData = rattachement.entite;
|
|
let personneData = rattachement.personne;
|
|
|
|
if (!entiteData && rattachement.entiteUrl) {
|
|
Logger.info('IdNot fetching entite data', { entiteUrl: rattachement.entiteUrl });
|
|
entiteData = await IdNotService.getEntiteData(rattachement.entiteUrl);
|
|
}
|
|
|
|
if (!personneData && rattachement.personneUrl) {
|
|
Logger.info('IdNot fetching personne data', { personneUrl: rattachement.personneUrl });
|
|
personneData = await IdNotService.getPersonneData(rattachement.personneUrl);
|
|
}
|
|
|
|
const userData = {
|
|
...rattachement,
|
|
entite: entiteData,
|
|
personne: personneData
|
|
};
|
|
|
|
// Log d'analyse (non sensible) pour diagnostiquer les cas de rattachement
|
|
Logger.info('IdNot userData summary', {
|
|
profile_idn: payload.profile_idn,
|
|
entity_idn: payload.entity_idn,
|
|
entiteTypeName: userData?.entite?.typeEntite?.name,
|
|
entiteCodeCrpcen: userData?.entite?.codeCrpcen,
|
|
statutDuRattachement: userData?.statutDuRattachement,
|
|
typeLien: userData?.typeLien?.name
|
|
});
|
|
|
|
if (!userData || !userData.statutDuRattachement || userData.entite.typeEntite.name !== 'office') {
|
|
throw new ForbiddenError('User not attached to an office');
|
|
}
|
|
|
|
// Get office location data
|
|
const officeLocationData = await IdNotService.getOfficeLocationData(userData.entite.locationsUrl);
|
|
|
|
if (!officeLocationData || !officeLocationData.result || officeLocationData.result.length === 0) {
|
|
throw new BusinessRuleError('Office location data not found');
|
|
}
|
|
|
|
// Build IdNotUser object
|
|
const idNotUser: IdNotUser = {
|
|
idNot: payload.sub,
|
|
office: {
|
|
idNot: payload.entity_idn,
|
|
name: userData.entite.denominationSociale ?? userData.entite.codeCrpcen,
|
|
crpcen: userData.entite.codeCrpcen,
|
|
office_status: IdNotService.getOfficeStatus(userData.entite.statutEntite.name),
|
|
address: {
|
|
address: officeLocationData.result[0].adrGeo4,
|
|
city: officeLocationData.result[0].adrGeoVille.split(' ')[0] ?? officeLocationData.result[0].adrGeoVille,
|
|
zip_code: Number(officeLocationData.result[0].adrGeoCodePostal)
|
|
},
|
|
status: 'ACTIVE'
|
|
},
|
|
role: IdNotService.getRole(userData.typeLien.name),
|
|
contact: {
|
|
first_name: userData.personne.prenom,
|
|
last_name: userData.personne.nomUsuel,
|
|
email: userData.mailRattachement,
|
|
phone_number: userData.numeroTelephone,
|
|
cell_phone_number: userData.numeroMobile ?? userData.numeroTelephone,
|
|
civility: IdNotService.getCivility(userData.personne.civilite)
|
|
},
|
|
office_role: IdNotService.getOfficeRole(userData.typeLien.name)
|
|
};
|
|
|
|
if (!idNotUser.contact.email) {
|
|
throw new UnauthorizedError('Email not found');
|
|
}
|
|
|
|
// Generate auth token
|
|
const authToken = uuidv4();
|
|
const tokenData: AuthToken = {
|
|
idNot: idNotUser.idNot,
|
|
authToken,
|
|
idNotUser: idNotUser,
|
|
pairingId: null,
|
|
defaultStorage: null,
|
|
createdAt: Date.now(),
|
|
expiresAt: Date.now() + (24 * 60 * 60 * 1000) // 24 hours
|
|
};
|
|
|
|
authTokens.push(tokenData);
|
|
|
|
Logger.info('IdNot authentication successful', {
|
|
idNot: idNotUser.idNot,
|
|
office: idNotUser.office.name
|
|
});
|
|
|
|
return { idNotUser, authToken };
|
|
|
|
} catch (error) {
|
|
Logger.error('IdNot authentication failed', {
|
|
codePrefix: code.substring(0, 8) + '...',
|
|
error: error instanceof Error ? error.message : 'Unknown error'
|
|
});
|
|
|
|
// Laisser passer les erreurs applicatives connues (4xx) pour éviter un 502 côté client
|
|
// Approche robuste: on tolère les divergences d'instance en vérifiant le statusCode et le nom
|
|
const maybeStatus = (error as any)?.statusCode;
|
|
const maybeName = (error as any)?.name as string | undefined;
|
|
|
|
// 1) Si une erreur possède un statusCode 4xx, on la relaisse passer
|
|
if (typeof maybeStatus === 'number' && maybeStatus >= 400 && maybeStatus < 500) {
|
|
throw error;
|
|
}
|
|
|
|
// 2) Si le nom correspond à une erreur applicative connue, on la relaisse passer
|
|
if (
|
|
error instanceof BusinessRuleError ||
|
|
error instanceof ForbiddenError ||
|
|
error instanceof UnauthorizedError ||
|
|
error instanceof ValidationError ||
|
|
maybeName === 'BusinessRuleError' ||
|
|
maybeName === 'ForbiddenError' ||
|
|
maybeName === 'UnauthorizedError' ||
|
|
maybeName === 'ValidationError'
|
|
) {
|
|
throw error;
|
|
}
|
|
|
|
throw new ExternalServiceError('IdNot', `Authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current user data by idNot and authToken
|
|
*/
|
|
static async getCurrentUser(idNot: string, authToken: string): Promise<{ success: boolean; data: IdNotUser }> {
|
|
Logger.info('Getting current user data', { idNot });
|
|
|
|
// Find the full token data
|
|
const userAuth = authTokens.find(auth => auth.authToken === authToken);
|
|
|
|
if (!userAuth || !userAuth.idNotUser) {
|
|
throw new NotFoundError('User data not found. Please log in again.');
|
|
}
|
|
|
|
Logger.info('Current user data retrieved', {
|
|
idNot,
|
|
office: userAuth.idNotUser.office.name
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
data: userAuth.idNotUser
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Logout user by removing auth token
|
|
*/
|
|
static async logout(authToken: string): Promise<{ success: boolean; message: string }> {
|
|
Logger.info('User logout initiated');
|
|
|
|
// Remove the auth token from the array
|
|
const tokenIndex = authTokens.findIndex(auth => auth.authToken === authToken);
|
|
|
|
if (tokenIndex > -1) {
|
|
const removedToken = authTokens.splice(tokenIndex, 1)[0];
|
|
Logger.info('User logout successful', {
|
|
idNot: removedToken.idNot
|
|
});
|
|
} else {
|
|
Logger.warn('Logout attempted with invalid token');
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Déconnexion réussie'
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate if a token is still valid
|
|
*/
|
|
static async validateToken(idNot: string): Promise<{ success: boolean; message: string; data: { idNot: string; valid: boolean } }> {
|
|
Logger.debug('Token validation requested', { idNot });
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Token valide',
|
|
data: {
|
|
idNot,
|
|
valid: true
|
|
}
|
|
};
|
|
}
|
|
}
|