Un utilisateur peut exister dans ta base et pas dans ton fournisseur d'identité. Rien ne crashe. Tout échoue en silence.
TL;DR : un compte à moitié supprimé casse quatre parcours d'un coup, sans une seule erreur utile. Ton code avale le 404 de l'IdP exprès, pour bloquer l'énumération de comptes. La réparation finit en SQL direct, parce que l'API admin de Keycloak ignore l'id que tu lui donnes. La prévention tient en deux choses : un ordre de suppression, et un invariant testé.
Cet article est pour les devs qui ont Keycloak, ou n'importe quel IdP, à côté d'une table users applicative.
Le contexte
Un IdP, un fournisseur d'identité, est le service qui authentifie tes utilisateurs. Keycloak est l'un des IdP open source les plus déployés.
Sur mon SaaS perso, Keycloak porte l'authentification. Ma base applicative porte le métier : profils, tenants, contrats. Le même utilisateur vit donc dans deux magasins, reliés par un UUID.
Je conçois aussi du SSO multi-région pour une plateforme santé à plus de 25 millions d'utilisateurs. Les échelles changent. Cette classe de bug, non.
La dérive : un nettoyage partiel
Un matin de mai, un compte de test refuse de s'inscrire. En base : une ligne users avec son UUID. Dans Keycloak : rien.
La cause était un nettoyage partiel. Un script de purge d'environnement de test avait supprimé des comptes dans un magasin sans toucher l'autre. Le garde-fou ne protégeait qu'un domaine de mail, pas le second.
La première passe d'assainissement a trouvé 539 comptes orphelins. Ce n'est pas un cas d'école. C'est une population.
Le diagnostic tient en deux requêtes :
-- Côté base applicative
SELECT id, email FROM users WHERE email = '[email protected]';
-- Côté Keycloak
SELECT id, email FROM user_entity WHERE email = '[email protected]';
-- Une ligne d'un côté, rien de l'autre : dérive.
Quatre symptômes, aucun ne pointe la cause
Un compte dans cet état casse quatre parcours en même temps.
Le magic link répond 200 et n'envoie rien. Un magic link est un lien de connexion envoyé par mail, sans mot de passe.
L'inscription répond 500 au premier essai. Puis 409 à tous les suivants, pour toujours.
L'acceptation d'invitation ne retrouve plus l'utilisateur.
Et les lignes métier restent accrochées à un UUID que plus personne ne peut charger.
Quatre symptômes, quatre tickets différents. Aucune stack trace commune. C'est le pire type d'incident : celui qui ne ressemble pas à un incident.
Pourquoi c'est invisible : ta propre sécurité
Pourquoi aucune erreur ne remonte ? Parce que je l'ai voulu.
L'énumération de comptes consiste à deviner quels emails ont un compte chez toi, en observant les réponses de l'API. Pour la bloquer, l'API doit répondre la même chose que le compte existe ou non.
Mon service avale donc le 404 de Keycloak, volontairement. Le magic link répond 200 dans tous les cas. La sécurité que j'ai posée a détruit mon observabilité.
La règle que j'en tire : masque l'erreur dans la réponse HTTP, jamais dans tes logs.
// Même réponse pour le client, que le compte existe ou non.
// La dérive, elle, doit rester visible pour toi.
user, err := idp.UserByEmail(ctx, email)
if errors.Is(err, ErrUserNotFound) {
slog.Warn("idp user missing", "flow", "magic_link")
metrics.IdpUserMissing.Inc()
respondOK(w) // anti-énumération : on ne dit rien dehors
return
}
Un 404 avalé sans métrique est une dette d'incident. Tu la rembourseras au pire moment.
La réparation : l'API admin ignore ton id
Pour réparer, il faut recréer l'utilisateur dans Keycloak avec le même UUID. Les lignes métier y sont accrochées.
Et là, surprise. L'API admin de Keycloak ignore le champ id que tu envoies à la création d'un utilisateur. Elle génère le sien. Ton UUID est irrécupérable par la porte officielle.
Il reste la porte de service : le SQL, directement dans les tables de Keycloak.
-- Recréer l'utilisateur en gardant l'UUID de la base applicative
INSERT INTO user_entity (id, realm_id, username, email, enabled, ...)
VALUES ('le-meme-uuid-que-ta-base', ...);
INSERT INTO user_attribute (user_id, name, value)
VALUES ('le-meme-uuid-que-ta-base', 'tenant_id', ...);
INSERT INTO user_role_mapping (user_id, role_id)
VALUES ('le-meme-uuid-que-ta-base', ...);
Écrire dans la base de Keycloak est un dernier recours, pas une habitude. Fais-le à froid. Vérifie ensuite avec kcadm, puis rejoue le parcours complet. Chez moi : demande de magic link, jeton généré, mail reçu.
La prévention : un ordre et un invariant
La réparation ne vaut rien sans la prévention. Trois changements ont suivi l'incident.
La suppression d'un compte est devenue une saga. Une saga est une suite d'étapes ordonnées, où chaque étape ne part que si la précédente a réussi. Chez moi : Keycloak d'abord, la base ensuite. Plus aucun script ne touche un seul des deux magasins.
Un endpoint d'admin liste la dérive : les UUID présents d'un côté et absents de l'autre. Ce qui était invisible devient une liste.
Et chaque purge se termine par un comptage des deux côtés. Si les nombres divergent, la purge a menti.
La règle générale : la cohérence entre deux magasins d'identité est un invariant à tester. Pas une convention à espérer.
La checklist anti-dérive
Si tu as un IdP et une table users, passe cette liste. Elle m'aurait évité un incident et 539 orphelins.
- Un seul chemin de suppression : la saga, jamais un script direct sur un seul magasin
- Chaque étape de la saga est conditionnée au succès de la précédente
- L'anti-énumération masque la réponse HTTP, jamais les logs ni les métriques
- Une métrique compte les 404 IdP avalés, avec une alerte dessus
- Un endpoint ou un job liste la dérive entre l'IdP et ta base
- Un test d'intégration crée, supprime, puis compte les deux côtés
- La procédure de réparation préserve l'UUID, et elle est écrite avant l'incident
Ce qu'il faut retenir
Deux magasins d'identité, c'est un système distribué. Avec ses promesses, et ses mensonges.
Le pire état n'est pas la panne. C'est le compte à moitié vivant, qui échoue en silence, protégé par ta propre sécurité.
Teste l'invariant. Compte les deux côtés. Et garde une trace interne de chaque 404 que tu avales.
Ton IdP et ta base racontent des histoires différentes en production ? Parlons-en.