Tous les articles

Tous les articles

Un SSO OIDC par organisation, avec une connexion qui commence par l'e-mail

Passer par le fournisseur d'identité de chaque organisation, via une connexion qui demande d'abord l'e-mail, quand ceux qui le configurent sont peu fiables.

Une organisation cliente de la plateforme peut faire passer ses membres par son propre fournisseur d'identité, en OpenID Connect, et la page de connexion décide où envoyer chacun à partir de sa seule adresse e-mail. Le token du fournisseur n'est lu qu'une fois, pendant le callback, puis la plateforme émet sa propre session, la même que pour une connexion par mot de passe. La rotation, la révocation et la liste de blocage s'appliquent donc telles quelles à une session SSO, et rien en aval ne sait par quel chemin la personne s'est connectée.

L'e-mail d'abord, puis le chemin#

La page de connexion ne demande qu'une adresse e-mail. Un endpoint public vérifie si cette adresse appartient à un domaine qu'une organisation a placé derrière son fournisseur, et la page affiche alors soit le champ du mot de passe, avec le focus déjà dedans, soit une redirection vers le fournisseur. Une adresse inconnue reçoit la même réponse qu'une adresse avec mot de passe, si bien que l'endpoint ne permet pas de savoir quels comptes existent. La recherche du domaine se fait en SQL plutôt qu'en chargeant tous les fournisseurs pour comparer en Python, puisqu'elle répond à n'importe qui.

Avant tout ça, le code qui décode un token de session avait été copié à quatre endroits : la dépendance des requêtes, les routes de rafraîchissement et de déconnexion, et la poignée de main WebSocket. Il a été regroupé dans un seul module, qui a reçu le seul comportement nouveau, une petite tolérance optionnelle au décalage d'horloge avec un fournisseur externe. Le même travail a fermé une brèche : la poignée de main WebSocket exige désormais un token d'accès, comme la voie HTTP l'a toujours fait, et refuse un token de rafraîchissement.

En libre-service, le fournisseur n'est pas fiable#

Les administrateurs d'une organisation configurent eux-mêmes leur fournisseur, et c'est voulu. Ça veut aussi dire que l'émetteur, ses clés et les domaines e-mail sont choisis par un client : tout ce que le fournisseur affirme vient d'une partie qui peut être hostile, et la conception découle du fait de la traiter comme telle.

La première version s'est trompée sur un point, et la revue l'a repéré avant le merge. Quand un fournisseur affirmait une adresse e-mail, la plateforme cherchait un compte existant avec cette adresse dans toutes les organisations. Un administrateur qui branchait son organisation sur un fournisseur qu'il contrôle aurait donc pu affirmer l'adresse d'une personne d'une autre organisation et recevoir sa session. Le provisionnement est désormais lié à l'organisation : un compte existant n'est relié que s'il est déjà membre de l'organisation dont le fournisseur s'est porté garant, et ajouter comme membre quelqu'un d'une autre organisation n'est pas à la portée d'un administrateur d'organisation. Créer un compte à la première connexion est désactivé tant que l'organisation ne l'active pas, et demande une adresse vérifiée.

Le rôle qu'un fournisseur attribue à ses nouveaux membres posait le même genre de problème. C'était un réglage écrit par l'administrateur, appliqué avec les privilèges du système à chaque connexion : il aurait pu accorder n'importe quel rôle. Il passe maintenant par la garde contre l'escalade qui limite déjà l'attribution manuelle des rôles : un administrateur peut donner aux nouveaux membres tout ce qu'il pourrait accorder à la main, et rien de plus.

Chaque appel sortant est une adresse choisie par un autre#

L'émetteur est une URL qu'un administrateur tape, et les endpoints du token et des clés en sont déduits : ce sont donc trois adresses que le serveur appelle sur la parole d'un client. C'est le cas d'école d'une falsification de requête côté serveur (SSRF). Chaque appel sortant passe par une seule garde qui exige HTTPS et refuse les adresses privées, de loopback, link-local et réservées, ce qui couvre un endpoint de métadonnées cloud et le réseau où tourne l'API. Vérifier le nom ne suffit pas, parce que la vérification et la connexion le résoudraient deux fois, et qu'un enregistrement DNS peut répondre autre chose la seconde fois. Le nom est donc résolu une seule fois, la connexion est épinglée à l'adresse vérifiée, et TLS continue de valider le certificat par rapport au nom d'hôte. Les redirections ne sont pas suivies.

Le document de découverte est confronté à l'émetteur configuré, et un document qui annonce un autre émetteur est refusé. Les clés sont mises en cache par émetteur, et quand un token arrive signé par une clé que le cache ne connaît pas, le jeu de clés est rechargé une fois avant de refuser le token : un fournisseur qui fait tourner ses clés ne bloque pas les connexions pendant une heure.

Les garanties du flux lui-même#

La demande d'autorisation utilise PKCE et un nonce, et l'état qui relie le callback à la demande est à usage unique, consommé par une lecture-suppression atomique, si bien qu'un callback rejoué ne trouve plus rien. L'usage unique empêche le rejeu, pas une CSRF de connexion, où quelqu'un démarre un flux et le fait terminer par sa victime, qui se retrouve dans le compte de l'attaquant. L'état est donc aussi lié au navigateur qui a démarré le flux, par un cookie de courte durée que le callback compare avant d'émettre quoi que ce soit.

Deux politiques de défaillance cohabitent, à dessein. Si le cache qui contient cet état est injoignable, la connexion échoue en mode fermé, avec un 503 et un délai de nouvelle tentative, parce que valider l'état est un contrôle de sécurité et qu'il n'est pas question de s'en passer. Les limites de débit par adresse sur les endpoints publics échouent en mode ouvert, parce qu'un raté du cache ne doit pas empêcher tout le monde de se connecter. Le secret client du fournisseur est chiffré au repos, peut être écrit mais jamais relu, et les journaux de connexion masquent les adresses e-mail.

Des échecs sur lesquels on peut agir#

Le callback est une navigation dans le navigateur, pas un appel d'API : un échec ne peut pas y répondre par une erreur JSON. Chaque échec définitif redirige vers la page de connexion avec l'une de trois raisons volontairement grossières : quelque chose a mal tourné et un nouvel essai peut aider, la tentative a expiré, ou l'accès a été refusé et c'est l'administrateur de l'organisation qu'il faut contacter. Le refus couvre tous les cas où un compte ne peut pas être utilisé, y compris celui d'un compte qui appartient à une autre organisation et celui d'un compte inexistant : la raison n'apprend rien à un attaquant sur les comptes hors de sa propre organisation.

Un domaine, un fournisseur#

Deux organisations peuvent revendiquer le même domaine e-mail, et la page de connexion doit en choisir une. Ma première réponse a été une vérification dans l'application avant d'activer un fournisseur, que deux requêtes simultanées pouvaient passer toutes les deux. C'est devenu une contrainte dans la base de données : chaque domaine revendiqué est reflété dans sa propre ligne, et un index unique partiel n'autorise qu'un seul fournisseur actif par domaine, si bien que la course ne peut pas être gagnée deux fois. Une modification en conflit revient sous forme d'erreur lisible plutôt que d'erreur serveur.

Le travail est passé par plusieurs tours de revue selon la grille qu'utilise le contrôle des pull requests avant d'être mergé, et le code SSO déclenche désormais la passe de sécurité approfondie de ce contrôle à chaque modification qui le touche. SAML a une place réservée dans la couche des fournisseurs, pour pouvoir l'ajouter plus tard sans toucher à ce qui l'appelle.