const { ManagementClient, AuthenticationClient } = require('auth0');
/**
* Action de liaison de comptes Auth0 - Version de production
*
* Dépendance requise : auth0@5.3.1
*
* Cette Action détecte les utilisateurs ayant des comptes en double (même courriel vérifié)
* et les redirige vers un service externe pour gérer la liaison de comptes.
*/
const ACCOUNT_LINKING_TIMESTAMP_KEY = 'account_linking_timestamp';
const TTL_LEEWAY_FACTOR = 0.2;
const PROPERTIES_TO_COMPLETE = ['given_name', 'family_name', 'name'];
/**
* Obtenir le jeton d'accès à la Management API avec mise en cache
*/
const getManagementAccessToken = async (event, api) => {
const cacheKey = `mgmt-api-token-${event.secrets.MANAGEMENT_API_CLIENT_ID}`;
const cached = api.cache.get(cacheKey);
if (cached && cached.value) {
return cached.value;
}
const auth = new AuthenticationClient({
domain: event.secrets.MANAGEMENT_API_DOMAIN,
clientId: event.secrets.MANAGEMENT_API_CLIENT_ID,
clientSecret: event.secrets.MANAGEMENT_API_CLIENT_SECRET
});
const response = await auth.oauth.clientCredentialsGrant({
audience: `https://${event.secrets.MANAGEMENT_API_DOMAIN}/api/v2/`
});
const accessToken = response.access_token || response.data?.access_token;
const expiresIn = response.expires_in || response.data?.expires_in;
if (accessToken && typeof accessToken === 'string') {
api.cache.set(cacheKey, accessToken, {
ttl: expiresIn - expiresIn * TTL_LEEWAY_FACTOR
});
}
return accessToken;
};
/**
* Obtenir les utilisateurs ayant la même adresse de courriel vérifiée
*/
const getUsersWithSameEmail = async (event, api) => {
const accessToken = await getManagementAccessToken(event, api);
const management = new ManagementClient({
domain: event.secrets.MANAGEMENT_API_DOMAIN,
token: accessToken
});
const users = await management.users.listUsersByEmail({
email: event.user.email
});
return users;
};
/**
* Filtrer et mapper les identités candidates avec courriel vérifié
*/
const getCandidateIdentitiesWithVerifiedEmail = (event, candidateUsers) => {
return candidateUsers
.filter((user) => user.user_id !== event.user.user_id && user.email_verified === true)
.filter((user) => user.identities && user.identities.length > 0)
.map((user) => ({
user_id: user.user_id,
provider: user.identities[0].provider,
connection: user.identities[0].connection
}));
};
/**
* Lier les comptes à l'aide de la Management API
* Lie l'identité secondaire À l'identité principale
*/
const linkAccounts = async (event, primaryIdentity, secondaryIdentity) => {
const accessToken = await getManagementAccessToken(event, { cache: { get: () => null, set: () => {} } });
// Extraire la partie de l'identifiant après le | pour l'API
const idParts = secondaryIdentity.user_id.split('|');
const userId = idParts.length > 1 ? idParts[1] : secondaryIdentity.user_id;
const url = `https://${event.secrets.MANAGEMENT_API_DOMAIN}/api/v2/users/${encodeURIComponent(primaryIdentity.user_id)}/identities`;
const body = {
provider: secondaryIdentity.provider,
user_id: userId
};
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Link API error: ${response.status} - ${errorText}`);
}
return await response.json();
};
/**
* Compléter les propriétés de profil manquantes à partir des identités liées
*/
const completeProperties = (event, api) => {
for (const property of PROPERTIES_TO_COMPLETE) {
if (!event.user[property]) {
for (const identity of event.user.identities) {
if (identity.profileData && identity.profileData[property]) {
api.idToken.setCustomClaim(property, identity.profileData[property]);
break;
}
}
}
}
};
/**
* onExecutePostLogin - Détecter les comptes en double et rediriger vers le service de liaison
*/
exports.onExecutePostLogin = async (event, api) => {
// Valider la configuration
if (
!event.secrets.MANAGEMENT_API_DOMAIN ||
!event.secrets.MANAGEMENT_API_CLIENT_ID ||
!event.secrets.MANAGEMENT_API_CLIENT_SECRET ||
!event.secrets.SESSION_TOKEN_SHARED_SECRET ||
!event.secrets.ACCOUNT_LINKING_SERVICE_URL
) {
console.log('Configuration requise manquante - liaison de comptes ignorée');
return;
}
// Nous ne traiterons pas les utilisateurs pour la liaison de comptes tant qu'ils n'ont pas vérifié leur adresse de courriel.
// Nous pourrions envisager de rejeter les connexions ici ou de rediriger les utilisateurs vers un outil externe pour
// rappeler à l'utilisateur de confirmer son adresse de courriel avant de continuer.
//
// Dans cet exemple, nous ne traiterons simplement pas les utilisateurs à moins que leur courriel ne soit vérifié.
if (!event.user.email_verified) {
return;
}
// Ignorer si déjà traité
if (event.user.app_metadata && event.user.app_metadata[ACCOUNT_LINKING_TIMESTAMP_KEY]) {
completeProperties(event, api);
return;
}
try {
// Trouver les utilisateurs ayant le même courriel
const candidateUsers = await getUsersWithSameEmail(event, api);
if (!Array.isArray(candidateUsers) || candidateUsers.length === 0) {
return;
}
// Filtrer pour les courriels vérifiés
const candidateIdentities = getCandidateIdentitiesWithVerifiedEmail(event, candidateUsers);
if (candidateIdentities.length === 0) {
return;
}
// Créer le jeton de session
const sessionToken = api.redirect.encodeToken({
payload: {
current_identity: {
user_id: event.user.user_id,
provider: event.connection.strategy,
connection: event.connection.name
},
candidate_identities: candidateIdentities,
email: event.user.email,
continue_url: `https://${event.request.hostname}/continue`
},
secret: event.secrets.SESSION_TOKEN_SHARED_SECRET,
expiresInSeconds: 120
});
// Rediriger vers le service de liaison
api.redirect.sendUserTo(event.secrets.ACCOUNT_LINKING_SERVICE_URL, {
query: {
session_token: sessionToken
}
});
} catch (err) {
console.error('Erreur de liaison de comptes :', err.message);
// Ne pas bloquer la connexion en cas d'erreur
}
};
/**
* onContinuePostLogin - Traiter la décision de liaison de l'utilisateur
*/
exports.onContinuePostLogin = async (event, api) => {
try {
// Valider le jeton de réponse
const { primary_identity: primaryIdentity, secondary_identity: secondaryIdentity } = api.redirect.validateToken({
secret: event.secrets.SESSION_TOKEN_SHARED_SECRET,
tokenParameterName: 'session_token'
});
if (!primaryIdentity || !secondaryIdentity) {
// L'utilisateur a annulé - continuer sans liaison
return;
}
const currentUserId = event.user.user_id;
// CRITIQUE : Basculer vers l'utilisateur principal AVANT la liaison
// Cela évite l'erreur « Unable to construct login user »
if (primaryIdentity.user_id !== currentUserId) {
api.authentication.setPrimaryUser(primaryIdentity.user_id);
}
// Lier le compte secondaire
const linkedIdentities = await linkAccounts(event, primaryIdentity, secondaryIdentity);
if (linkedIdentities && linkedIdentities.length > 0) {
// Marquer comme traité
api.user.setAppMetadata(ACCOUNT_LINKING_TIMESTAMP_KEY, Date.now());
completeProperties(event, api);
} else {
api.access.deny('Échec de la liaison de comptes');
}
} catch (err) {
console.error('Erreur onContinuePostLogin :', err.message);
api.access.deny('Erreur de liaison de comptes : ' + err.message);
}
};