const { ManagementClient, AuthenticationClient } = require('auth0');
/**
* Auth0 Account Linking Action - Production Version
*
* Required dependency: auth0@5.3.1
*
* This Action detects users with duplicate accounts (same verified email)
* and redirects them to an external service to manage account linking.
*/
const ACCOUNT_LINKING_TIMESTAMP_KEY = 'account_linking_timestamp';
const TTL_LEEWAY_FACTOR = 0.2;
const PROPERTIES_TO_COMPLETE = ['given_name', 'family_name', 'name'];
/**
* Get Management API access token with caching
*/
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;
};
/**
* Get users with the same verified email address
*/
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;
};
/**
* Filter and map candidate identities with verified email
*/
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
}));
};
/**
* Link accounts using Management API
* Links secondary identity TO primary identity
*/
const linkAccounts = async (event, primaryIdentity, secondaryIdentity) => {
const accessToken = await getManagementAccessToken(event, { cache: { get: () => null, set: () => {} } });
// Extract the ID part after the | for the 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();
};
/**
* Complete missing profile properties from linked identities
*/
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 - Detect duplicate accounts and redirect to linking service
*/
exports.onExecutePostLogin = async (event, api) => {
// Validate 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('Missing required configuration - skipping account linking');
return;
}
// We won't process users for account linking until they have verified their email address.
// We might consider rejecting logins here or redirecting users to an external tool to
// remind the user to confirm their email address before proceeding.
//
// In this example, we simply won't process users unless their email is verified.
if (!event.user.email_verified) {
return;
}
// Skip if already processed
if (event.user.app_metadata && event.user.app_metadata[ACCOUNT_LINKING_TIMESTAMP_KEY]) {
completeProperties(event, api);
return;
}
try {
// Find users with same email
const candidateUsers = await getUsersWithSameEmail(event, api);
if (!Array.isArray(candidateUsers) || candidateUsers.length === 0) {
return;
}
// Filter for verified emails
const candidateIdentities = getCandidateIdentitiesWithVerifiedEmail(event, candidateUsers);
if (candidateIdentities.length === 0) {
return;
}
// Create session token
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
});
// Redirect to linking service
api.redirect.sendUserTo(event.secrets.ACCOUNT_LINKING_SERVICE_URL, {
query: {
session_token: sessionToken
}
});
} catch (err) {
console.error('Account linking error:', err.message);
// Don't block login on error
}
};
/**
* onContinuePostLogin - Process user's linking decision
*/
exports.onContinuePostLogin = async (event, api) => {
try {
// Validate response token
const { primary_identity: primaryIdentity, secondary_identity: secondaryIdentity } = api.redirect.validateToken({
secret: event.secrets.SESSION_TOKEN_SHARED_SECRET,
tokenParameterName: 'session_token'
});
if (!primaryIdentity || !secondaryIdentity) {
// User cancelled - continue without linking
return;
}
const currentUserId = event.user.user_id;
// CRITICAL: Switch to primary user BEFORE linking
// This prevents "Unable to construct login user" error
if (primaryIdentity.user_id !== currentUserId) {
api.authentication.setPrimaryUser(primaryIdentity.user_id);
}
// Link the secondary account
const linkedIdentities = await linkAccounts(event, primaryIdentity, secondaryIdentity);
if (linkedIdentities && linkedIdentities.length > 0) {
// Mark as processed
api.user.setAppMetadata(ACCOUNT_LINKING_TIMESTAMP_KEY, Date.now());
completeProperties(event, api);
} else {
api.access.deny('Account linking failed');
}
} catch (err) {
console.error('onContinuePostLogin error:', err.message);
api.access.deny('Account linking error: ' + err.message);
}
};