Notre connexion se figeait pendant exactement 10 secondes. Nos propres en-têtes de sécurité en étaient la cause.
Pendant environ une semaine, les utilisateurs récurrents qui ouvraient notre portail voyaient le mot « Loading… » rester affiché à l'écran pendant dix secondes avant que la page de connexion apparaisse. Pas de temps en temps. Pas à peu près. Dix secondes, à chaque fois, puis tout fonctionnait parfaitement. Les nouveaux visiteurs ne le voyaient jamais. Seulement les personnes qui s'étaient déjà connectées auparavant.
Un bug qui prend un temps aléatoire est un problème de performance. Un bug qui prend exactement dix secondes est un aveu. Rien dans une requête web saine ne s'arrondit à une puissance de dix bien nette. Ce chiffre n'est pas la somme d'un travail réel ; c'est le plafond d'un délai d'expiration, et un délai d'expiration signifie que quelque chose, quelque part, attend patiemment une chose qui n'arrivera jamais.
Voici l'histoire de ce qu'il attendait, et de la raison pour laquelle la chose qu'il attendait était bloquée par un en-tête de sécurité dont nous étions fiers.
Ce que fait le portail au montage
Notre portail est une application monopage. Quand elle se charge, avant de vous montrer quoi que ce soit, elle essaie de répondre à une seule question : êtes-vous déjà connecté ? La façon polie de le faire avec OIDC est une vérification silencieuse. L'application demande au fournisseur d'identité : « si ce navigateur possède déjà une session, donne-moi un jeton frais sans importuner l'utilisateur ». Notre bibliothèque cliente, oidc-client-ts, expose cela sous la forme signinSilent(), que nous appelions au montage depuis une fonction utilitaire renewSession().
Cette vérification silencieuse peut se dérouler de deux façons. Si l'application détient un jeton de rafraîchissement, la bibliothèque effectue un échange discret par canal détourné, sans aucune interface, et vous êtes connecté. Si elle ne détient pas de jeton de rafraîchissement, la bibliothèque se rabat sur le mécanisme plus ancien : elle ouvre une iframe cachée pointée vers le point de terminaison d'autorisation du fournisseur d'identité avec prompt=none, et attend que cette iframe renvoie un résultat. Tout l'intérêt de l'iframe est qu'elle est invisible. Vous n'êtes jamais censé la voir, et sur une configuration saine vous ne la voyez jamais, parce qu'elle se résout en quelques millisecondes.
La nôtre ne se résolvait jamais du tout.
Le mur que nous avons construit nous-mêmes
L'iframe charge l'hôte d'authentification. Et l'hôte d'authentification, comme tout hôte que nous exploitons et qui a la moindre prétention à être pris au sérieux, expédie deux en-têtes dont le seul rôle est de dire « vous ne pouvez pas me placer dans un cadre » :
X-Frame-Options: DENYContent-Security-Policy: frame-ancestors 'none'
Ce sont des défenses contre le clickjacking, et elles sont correctes. Un attaquant capable de placer votre page de connexion dans une iframe peut la faire flotter sous un leurre, pousser un utilisateur à saisir de vrais identifiants dans ce qui ressemble à quelque chose d'inoffensif, et les récolter. frame-ancestors 'none' est l'instruction moderne stipulant qu'aucune origine, pas même la nôtre, ne peut intégrer cette page. Nous l'avons activée délibérément. C'est exactement le genre de chose qu'une revue de sécurité recherche et récompense.
Ainsi, quand oidc-client-ts a ouvert son iframe cachée vers cet hôte, le navigateur a fait précisément ce que nous lui avions demandé : il a refusé de rendre la page dans un cadre. Et voici le côté cruel. Un cadre refusé ne lève aucune exception. Il n'y a aucun événement d'erreur que la bibliothèque puisse intercepter, aucune promesse rejetée, aucune ligne dans la console. L'iframe reste simplement là, vide, indéfiniment. Du point de vue de la bibliothèque, rien ne s'est encore produit, alors elle fait la seule chose qu'elle puisse faire. Elle attend son délai d'expiration. Ce délai, le silentRequestTimeout par défaut, est de dix secondes.
Dix secondes d'une iframe cachée fixant un mur vide, puis la bibliothèque abandonne, la promesse finit par être rejetée, l'application hausse les épaules et vous redirige vers la vraie page de connexion, et tout fonctionne. Le blocage n'a jamais été un échec. C'était une réussite qui avait pris le chemin des écoliers à travers une iframe condamnée.
Deux choses correctes, une mauvaise jointure
Ce qui rendait la chose véritablement difficile à voir, c'est que rien n'était cassé. Les en-têtes de sécurité étaient corrects. Le repli sur le renouvellement silencieux était correct, un modèle OIDC légitime et largement utilisé. Chaque composant se comportait exactement comme prévu et exactement comme n'importe quel relecteur le souhaiterait. Le blocage de dix secondes ne résidait dans aucun d'entre eux. Il résidait dans l'espace entre eux, dans l'hypothèse que chacun formulait au sujet de l'autre. La bibliothèque SSO supposait qu'elle pouvait placer le fournisseur d'identité dans un cadre. Le fournisseur d'identité supposait que personne ne devrait jamais être autorisé à le placer dans un cadre. Les deux hypothèses étaient défendables. Elles étaient simplement incompatibles, et aucun fichier à lui seul ne contenait la contradiction.
Pourquoi elle a eu recours à l'iframe en premier lieu
Il restait quand même une question. Le chemin rapide, l'échange de jeton de rafraîchissement, aurait entièrement évité l'iframe. Pourquoi les utilisateurs récurrents se retrouvaient-ils sur le chemin lent ? Parce qu'ils n'avaient aucun jeton de rafraîchissement à détenir. Et ils n'avaient aucun jeton de rafraîchissement parce que nos propres clients OAuth du portail avaient été provisionnés sans AllowOfflineAccess, le drapeau qui autorise un client à s'en voir délivrer un. Pas d'accès hors ligne, pas de jeton de rafraîchissement, pas de chemin rapide, et chaque utilisateur récurrent était aiguillé vers l'iframe qui ne pouvait jamais se charger.
C'était là le vrai défaut, et c'était un problème de données réparti sur chaque locataire, pas une modification de code d'une ligne que nous pouvions livrer une seule fois. La réparation est donc un service de réconciliation qui réapplique AllowOfflineAccess aux clients de portail de chaque locataire au démarrage, corrigeant toute la flotte au prochain déploiement sans que personne ait à toucher un locataire à la main. Les jetons de rafraîchissement ont recommencé à circuler, et le chemin rapide est revenu à la vie de lui-même.
Le correctif, et la leçon
Le service de réconciliation a corrigé la cause. Mais une connexion ne devrait pas se bloquer pendant dix secondes, même lorsqu'elle finit sur le chemin lent, alors nous avons aussi renforcé la jointure. renewSession() inspecte désormais d'abord l'utilisateur stocké : s'il n'y a aucun jeton de rafraîchissement en main, il court-circuite et ne renvoie rien immédiatement, évite l'iframe qu'il sait déjà condamnée, et envoie l'utilisateur directement vers la connexion interactive. Le chemin rapide du jeton de rafraîchissement reste intact. Et comme filet de sécurité pour tout renouvellement en arrière-plan qui ouvre encore une iframe, nous avons ramené le délai d'expiration de dix secondes à cinq, de sorte que le pire cas soit deux fois moins grave.
La leçon que nous avons réellement retenue porte sur une catégorie de bugs, pas sur ce seul cas. Un blocage est un bug, même s'il n'émet aucune erreur, aucune exception, aucune ligne rouge dans un journal. La seule preuve qu'il laisse est le temps écoulé. Et quand ce temps est un chiffre rond bien net, ne partez pas à la chasse au travail lent à optimiser. Partez à la chasse à un délai d'expiration, puis trouvez la chose à l'autre bout qui, silencieusement, définitivement, ne répondra jamais. La nôtre était une iframe, frappant poliment à une porte que nous avions verrouillée à double tour exprès.
Si vous préférez que votre flux de connexion sache déjà qu'un hôte d'authentification verrouillé et une iframe de renouvellement silencieux ne font pas bon ménage, c'est une jointure sur laquelle nous nous sommes déjà heurtés pour que vous n'ayez jamais à le faire. Authagonal livre la plomberie SSO et les en-têtes de sécurité comme un seul système testé conjointement, et non comme deux moitiés correctes que vous découvrez incompatibles à raison de dix secondes par chargement de page.