← All posts

Un BFF pour de nombreux locataires, et la seule requête qui arrive sans cookie

Authagonal·July 28, 2026

On a appris aux applications monopages à conserver leurs propres jetons. L'application exécute le flux OAuth dans le navigateur, reçoit un jeton d'accès et le plus souvent un jeton de rafraîchissement, les garde quelque part en JavaScript et les joint à chaque appel d'API. C'est bien documenté, c'est ce que montrent la plupart des tutoriels, et cela place votre identifiant le plus durable à l'unique endroit que vous ne pouvez pas défendre : un environnement d'exécution qui exécute allègrement tout ce qui parvient jusqu'à la page. Une injection réussie, une dépendance compromise quelque part dans votre build, et plus personne n'a besoin d'hameçonner qui que ce soit. On lit le jeton et on repart, et il continue de fonctionner jusqu'à son expiration.

Le motif backend-for-frontend met l'identifiant hors de portée. Le navigateur dialogue avec un petit serveur qui appartient à votre application, et ce serveur est le client OAuth confidentiel. Il exécute l'échange de code, il détient les jetons d'accès et de rafraîchissement, et il ne remet au navigateur qu'un cookie opaque. Voici la description de la façon dont nous avons construit le nôtre, et de la seule partie qui s'est révélée vraiment intéressante : lui faire servir plus d'un locataire.

Ce qu'il reste au navigateur

Un cookie nommé __Host-agbff, marqué HttpOnly, SameSite=Lax, applicable à tout le chemin, sans expiration afin qu'il meure avec la session du navigateur. Sa valeur, ce sont 256 bits d'aléa et rien d'autre. Ce n'est pas un jeton, il ne se décode en rien, et le voler sur le fil n'est pas quelque chose que l'on peut faire à un cookie __Host- sur TLS.

Tout ce qui est réel se trouve côté serveur, dans un enregistrement de session au sein d'un cache distribué : le jeton d'accès, le jeton de rafraîchissement, le jeton d'identité, l'instant d'expiration du jeton d'accès, et le locataire auquel appartient la session. La connexion elle-même est un flux de code d'autorisation ordinaire avec PKCE, mené par un client confidentiel qui s'authentifie auprès du point de terminaison de jetons à l'aide de son secret. Le jeton d'identité est validé comme il se doit au retour : émetteur, audience, signature vérifiée contre les clés publiées, durée de vie, puis une comparaison à temps constant de la nonce avec la valeur mise de côté avant la redirection. Ce n'est qu'après tout cela qu'une session existe et qu'un cookie est posé.

Un en-tête dont on vérifie l'existence, et rien d'autre

Le navigateur appelle le BFF pour obtenir ses propres informations utilisateur et pour atteindre l'API relayée, et ces appels portent un en-tête personnalisé, x-authagonal-bff. Sa valeur n'a aucune importance. Sa présence constitue toute la vérification.

Cela a l'air paresseux, mais ne l'est pas. Toute la classe des attaques de falsification de requête intersites repose sur un envoi de formulaire, une balise image ou une navigation qu'une autre origine peut déclencher pendant que votre cookie suit le mouvement, et aucune d'elles ne peut définir un en-tête personnalisé. Dès l'instant où un JavaScript malveillant tente d'en ajouter un, ce n'est plus une requête simple mais une requête avec pré-vérification, que votre politique CORS refuse. L'en-tête n'est pas un secret à deviner, c'est la preuve que la requête provient de code et non de balisage.

Il est exigé sur les appels scriptés et délibérément pas sur ceux qui sont par nature des navigations de premier niveau : lancer une connexion, revenir du fournisseur d'identité, suivre un lien de déconnexion. Exiger un en-tête personnalisé sur une navigation de navigateur ne ferait que casser la navigation.

Rafraîchir, exactement une fois

Chaque requête relayée peut découvrir que le jeton d'accès est sur le point d'expirer, et une page chargée émet plusieurs requêtes à la fois. Rafraîchissez-les toutes naïvement et vous obtenez un petit désastre : plusieurs rafraîchissements simultanés avec un jeton de rafraîchissement rotatif, chacun invalidant les autres, pour finir par déconnecter l'utilisateur au moyen de son propre trafic.

C'est pourquoi le rafraîchissement est en vol unique, par session. La première requête qui passe s'empare d'une barrière indexée par l'identifiant de session, et toutes les autres attendent. Le point subtil est ce que font les requêtes en attente lorsqu'elles entrent à leur tour : elles relisent la session depuis le magasin avant de décider quoi que ce soit, car la requête qui a tenu la barrière en premier a très probablement déjà rafraîchi, et la valeur qu'elles tenaient au moment de faire la queue est périmée. Rafraîchir une fois, puis tout le monde utilise le résultat.

Il tient aussi compte de la rotation, dans le sens ennuyeux et nécessaire. Si le point de terminaison de jetons renvoie un nouveau jeton de rafraîchissement, celui-ci remplace celui qui était stocké. Si le rafraîchissement échoue sur une erreur de jeton, la session est supprimée et l'utilisateur est déconnecté, car un jeton de rafraîchissement qui a été rejeté ne sera pas accepté à la tentative suivante, et réessayer n'est qu'une façon plus lente de se faire déconnecter.

Un seul BFF, de nombreux locataires

Un BFF mono-locataire résout tout à partir de la configuration au démarrage : une seule autorité, un seul identifiant de client, un seul secret. Servir de nombreux locataires depuis un même déploiement casse cela, car le BFF doit désormais déterminer à quel émetteur appartient une connexion, et il doit le faire avant qu'aucune session n'existe.

La sélection a lieu au point de terminaison de connexion, via un paramètre de requête que vous nommez vous-même, il peut donc s'agir de ?slug= ou de ?org= ou de la manière dont votre produit désigne déjà un client. Un résolveur transforme cette clé en une configuration de locataire : autorité, identifiant de client, secret, portées. Si elle ne résout rien, la connexion est refusée sur-le-champ.

La clé doit ensuite survivre à un aller-retour vers un fournisseur d'identité qui n'en sait rien, puis revenir. Aucune session n'existe encore pour la conserver, elle voyage donc dans le cookie de corrélation, ce cookie chiffré de courte durée de vie qui transporte déjà, pour exactement cette raison, le vérificateur PKCE, l'état et la nonce. Quinze minutes, un par tentative de connexion. Au retour, le cookie est déchiffré, le locataire est de nouveau résolu à partir de la clé qu'il contient, et le code est échangé auprès du point de terminaison de jetons de ce locataire. Ce n'est qu'alors que la clé de locataire est écrite dans la session, où elle devient la réponse durable.

À partir de là, chaque chemin résout à nouveau depuis la session plutôt que depuis quoi que ce soit qu'a dit le navigateur : rafraîchissement, déconnexion, le proxy. Et lorsqu'un locataire cesse de se résoudre, parce qu'il a été supprimé ou désactivé, la session est détruite plutôt que de se rabattre discrètement sur un quelconque locataire par défaut. Un locataire non résoluble est une déconnexion, pas un haussement d'épaules.

C'est avec la déconnexion back-channel que cette conception justifie son existence. Le fournisseur d'identité envoie par POST un jeton de déconnexion directement à votre serveur lorsqu'une session se termine ailleurs. Pas de cookie. Pas de session. Aucun navigateur du tout. Dans un BFF mono-locataire, c'est banal, puisqu'il n'existe qu'un seul émetteur d'où il puisse provenir. Dans un BFF multi-locataire, vous devez répondre à « pour quel locataire est-ce ? » avant de pouvoir répondre à « est-ce seulement authentique ? », et le seul matériau disponible est le jeton lui-même, que vous n'avez pas vérifié et auquel vous ne pouvez donc pas vous fier.

L'ordre qui rend cela sûr mérite d'être énoncé avec précision. Lisez la revendication d'émetteur dans le jeton non vérifié, et servez-vous-en pour une seule chose : choisir quelle configuration de locataire charger. Vérifiez ensuite la signature du jeton contre les clés publiées de ce locataire et son identifiant de client comme audience. Un émetteur falsifié choisit le locataire qu'il prétend être, puis échoue à la vérification contre les clés de ce locataire, parce que l'attaquant ne les possède pas. Rien n'a jamais été accepté sur la foi de la revendication non fiable. La revendication a seulement décidé qui a le droit de la juger, et le juge est une ancre de confiance que nous avions déjà.

Viennent ensuite les contrôles qui font d'un jeton de déconnexion un vrai jeton de déconnexion : il ne doit pas porter de nonce, puisqu'il n'authentifie personne ; il doit porter l'événement de déconnexion back-channel ; et il doit identifier soit une session précise, soit un sujet. Un identifiant de session met fin à une session. Un sujet met fin à toutes les sessions de cet utilisateur, et c'est la forme que nous émettons, car « déconnecte-moi partout » est ce que les gens veulent réellement dire lorsqu'ils cliquent sur se déconnecter partout. Voilà pourquoi le magasin de sessions tient un index par identifiant de session et un autre par sujet, plutôt que seulement par valeur de cookie.

Le mono-locataire reste ennuyeux

Tout cela reste inerte dans le cas courant. Le résolveur par défaut renvoie la même configuration quelle que soit la clé qu'on lui remet, et sa résolution par émetteur renvoie cette configuration sans condition, parce qu'il n'y en a qu'une. Un BFF mono-locataire se comporte exactement comme il le faisait avant que cette jointure existe : aucun paramètre de locataire, rien de plus dans le cookie, aucun nouveau mode de défaillance. La mécanique multi-locataire est facultative et invisible jusqu'à ce que vous nommiez un paramètre de requête.

Séparez la sélection de la confiance

Lorsque vous rendez quelque chose multi-locataire, l'exercice qui vaut la peine consiste à énumérer chaque point d'entrée et à demander ce qui y identifie le locataire. Les navigations de navigateur portent un cookie que vous avez émis et que vous contrôlez. Les rappels de redirection portent un état que vous avez signé. Les rappels de serveur à serveur ne portent que ce que l'appelant a choisi d'envoyer, et l'appelant ment peut-être.

Le point d'entrée délicat, celui qui ne porte rien de digne de confiance, est celui dont la conception décide si l'ensemble du système est solide, et c'est en général celui auquel personne ne pense avant qu'il ne soit tard. Le motif qui nous en a sortis se généralise : séparez la sélection de la confiance. Il est acceptable de router sur des données non fiables tant que le routage ne fait que choisir quelle ancre de confiance a le droit de rendre la décision finale, et que la décision finale est rendue par quelque chose que l'attaquant ne peut pas falsifier.

Si vous préférez ne pas écrire tout cela vous-même, Authagonal fournit le BFF pour .NET et pour Node, et le portail approvisionnera le client confidentiel dont il a besoin, URI de redirection, point de terminaison de déconnexion back-channel et tout le reste, en un seul clic.