On a sorti l'auth de l'application. Ça a marché en local... et posé queques problèmes sur GCP.

Portail de connexion en local ou sur cloud run (GCP) Image générée avec Nano Banana

Il y a un problème récurrent dans les architectures à plusieurs services : l’authentification finit par être gérée dans chaque application séparément.

Le frontend vérifie le token. Le backend revérifie le token. Et parfois un troisième service vérifie à nouveau parce que personne n’était sûr que les deux premiers le faisaient correctement. Chaque nouvelle application embarque son propre middleware d’authentification avec ses propres subtilités, ses propres angles morts et sa propre façon de rater le cas d’expiration.

Il existe un schéma probablement plus propre : sortir l’authentification de l’application et la gérer une seule fois, en amont, au niveau du proxy. Aucune requête non authentifiée n’atteint votre stack. Les applications reçoivent des requêtes déjà validées, avec le contexte utilisateur dans les en-têtes. Elles n’ont plus à se préoccuper du flow OIDC — seulement de l’autorisation, qui est leur responsabilité légitime.

C’est ce qu’on a essayé de faire avec OpenResty sur une plateforme digitale grands comptes. En local, ça fonctionnait. Sur Cloud Run trois problèmes que nous n’avions pas anticipés ont tout bloqué.


Le schéma cible : un proxy qui authentifie avant de laisser passer

L’idée est simple à décrire. Une requête arrive. Avant qu’elle atteigne votre application, un reverse proxy intercepte la requête et vérifie l’identité de l’appelant. Si l’utilisateur n’est pas authentifié, le proxy initie le flow OIDC et redirige vers Keycloak. Si l’utilisateur est authentifié, le proxy valide le token, injecte quelques headers (X-User-ID, X-User-Email, le JWT pour les appels API), et laisse passer la requête.

Du point de vue de l’application, chaque requête qui arrive est déjà authentifiée. Elle n’a pas à gérer les redirections, les callbacks, les tokens expirés. Elle fait confiance aux headers qu’elle reçoit et se concentre sur sa logique métier.

Ce schéma s’appelle parfois sidecar auth, parfois auth gateway, parfois zero-trust ingress. Il est utilisé par des outils comme OAuth2 Proxy, Envoy avec des filtres ext-authz, ou Google Identity-Aware Proxy. Nous avons choisi de l’implémenter avec OpenResty.


Pourquoi OpenResty

OpenResty c’est Nginx avec LuaJIT embarqué. Vous écrivez des blocs Lua directement dans la configuration Nginx — dans les phases access_by_lua_block, header_filter_by_lua_block, content_by_lua_block. Ces blocs ont accès à l’ensemble des primitives Nginx : la requête, les en-têtes, les variables, les connexions upstream.

Ce qui rend OpenResty pertinent pour ce cas d’usage, c’est l’écosystème de bibliothèques Lua disponibles via OPM, le package manager d’OpenResty :

  • lua-resty-openidc — implémente le flow OIDC Authorization Code complet : discovery, redirect, callback, validation du token, gestion de session. C’est la lib centrale du pattern.
  • lua-resty-jwt — validation locale des JWT avec vérification de signature JWKS.
  • lua-resty-session — gestion des sessions côté serveur ou via cookie chiffré.
  • lua-resty-http — client HTTP non-bloquant pour les appels vers Keycloak.

L’alternative aurait été OAuth2 Proxy — un outil dédié à ce cas d’usage, moins flexible mais plus simple à opérer. Nous avons choisi OpenResty parce que nous avions déjà nginx dans la stack et que nous voulions garder la main sur le routing.


L’architecture cible

La plateforme sur laquelle nous travaillions était composée de trois services Cloud Run : un frontend Next.js, une API Laravel, et un Keycloak géré séparément. L’objectif était simple :

Internet → OpenResty (auth gateway) → Next.js (privé)
                                    → Laravel (privé, via Next.js)

OpenResty était le seul service exposé publiquement. Next.js et Laravel étaient configurés en accès privé sur Cloud Run — accessibles uniquement par des service accounts autorisés, pas depuis l’extérieur.

Le flow attendu :

  1. L’utilisateur arrive sur app.exemple.com
  2. OpenResty détecte l’absence de session → redirige vers Keycloak
  3. L’utilisateur s’authentifie sur Keycloak → callback vers OpenResty
  4. OpenResty valide le token, crée une session, injecte les en-têtes
  5. La requête est proxifiée vers Next.js avec Authorization: Bearer <jwt>, X-User-ID, X-User-Email
  6. Next.js n’a aucune logique d’authentification — seulement de l’autorisation sur les routes

Le gateway ne se comporte pas de la même façon selon la nature de la route.

Pour les routes qui nécessitent une authentification — l’API, les pages réservées aux utilisateurs connectés — il bloque toute requête sans session valide et redirige vers Keycloak. Pas de session ? Pas de passage.

Pour les routes publiques, il adopte un comportement différent : si une session existe, il injecte les en-têtes d’identité ; si elle n’existe pas, il laisse passer la requête sans les ajouter. L’application peut alors adapter son affichage à l’utilisateur connecté, sans l’obliger à s’authentifier pour accéder à des pages publiques.

Comment les backends font confiance aux headers

Ce pattern soulève une question immédiate : comment Next.js et Laravel savent-ils que les en-têtes X-User-ID ou X-User-Roles qu’ils reçoivent viennent bien du gateway, et non d’un attaquant qui les aurait simplement forgés ?

C’est le rôle d’un jeton interne partagé. Imaginez un service courrier d’entreprise : tout courrier qui passe par le circuit officiel reçoit un cachet interne. Un courrier arrivé sans ce cachet — même s’il prétend venir de l’intérieur — est rejeté à l’entrée.

Concrètement : le gateway ajoute un header X-Internal-Token sur chaque requête qu’il proxifie, avec un secret configuré en variable d’environnement. Les backends vérifient ce header en premier, avant de lire quoi que ce soit d’autre. Si le header est absent ou incorrect, la requête est rejetée — peu importe ce que disent les autres headers. Un attaquant qui forgerait X-User-ID: admin se verrait bloqué par ce premier contrôle.

Ce mécanisme ne remplace pas le chiffrement mutuel entre services (mTLS) dans un environnement de production durci. Mais il constitue une première barrière efficace, simple à implémenter, et qui couvre l’essentiel des cas.

Un modèle d’identité sans état (stateless)

Une conséquence importante de ce pattern : les backends n’ont plus besoin de stocker les utilisateurs.

Puisque chaque requête arrive avec X-User-ID, X-User-Email, X-User-Name et X-User-Roles dans les en-têtes, le backend peut reconstruire l’identité à la volée — sans base de données, sans table users, sans sessions côté serveur. Keycloak est la seule source de vérité sur l’identité. C’est lui qui détient les comptes, les mots de passe, les rôles.

Les rôles définis dans Keycloak arrivent dans X-User-Roles sous forme de liste, et chaque service décide de ce qu’il autorise en fonction de ce header — afficher un bouton dans l’interface, rejeter une action, retourner une erreur 403 — sans jamais interroger Keycloak directement.

Sur le papier, c’est propre. Et en local, avec docker-compose, ça fonctionnait.


Ce qui a bloqué sur Cloud Run

1. Le resolver DNS — le problème le plus sournois

OpenResty fait de la résolution DNS dynamique au moment de chaque requête pour les domaines upstream définis via des variables d’environnement. Il a besoin d’un resolver configuré explicitement dans Nginx.conf.

Dans notre configuration, on avait :

resolver 8.8.8.8 1.1.1.1 ipv6=off;

En local, c’est parfait. Sur Cloud Run, c’est cassé — de manière non évidente.

Les services Next.js et Laravel étaient exposés sur des domaines internes au VPC (*.internal.exemple.net). Ces domaines ne sont pas résolvables via les DNS publics 8.8.8.8 ou 1.1.1.1. Ils ne sont visibles que depuis le resolver interne de GCP — typiquement 169.254.169.254 en environnement Cloud Run, ou le resolver du VPC peering.

Le symptôme : les requêtes proxifiées vers Next.js aboutissaient à des erreurs no resolver defined to resolve ou des timeouts silencieux. Rien dans les logs Nginx n’indiquait clairement un problème DNS — on voyait juste une connexion upstream qui n’arrivait pas à s’établir.

La correction est triviale une fois qu’on a compris le problème :

resolver 169.254.169.254 ipv6=off valid=10s;

Mais trouver ce problème a pris du temps — parce qu’on cherchait dans la configuration applicative, pas dans la résolution réseau.

Il y a un second problème DNS, plus subtil, lié à la manière dont Keycloak publie sa configuration.

Quand le gateway démarre, il appelle l’URL de découverte OIDC de Keycloak — un point d’entrée standard qui retourne tous les endpoints du serveur : URL de token, clés publiques de signature, URL de déconnexion. Keycloak construit ces URLs en se basant sur le header Host de la requête qu’il reçoit.

Si le gateway appelle Keycloak via son nom interne — http://auth:8080 dans un docker-compose — Keycloak répond avec un document dont toutes les URLs commencent par http://auth:8080/.... Ces adresses sont internes au réseau du conteneur. Le navigateur de l’utilisateur ne peut pas les résoudre. Le flux OIDC est cassé dès la première redirection, même si la résolution DNS du gateway lui-même fonctionne parfaitement.

La correction : forcer le header Host dans les appels de découverte que le gateway envoie à Keycloak, pour que ce dernier génère ses URLs avec le domaine public. Les appels réseau restent internes pour la performance, mais les URLs du document de découverte sont correctes pour le navigateur.

2. Les sessions ne survivent pas au multi-instances

Cloud Run scale horizontalement. Quand le trafic augmente, plusieurs instances de votre conteneur OpenResty tournent en parallèle.

lua-resty-session par défaut stocke les sessions en mémoire de l’instance (ou dans un cookie chiffré côté client). Le problème se manifeste dans le flow OIDC Authorization Code :

  1. L’instance A initie le flow → génère un state OIDC, le stocke en session locale
  2. Keycloak redirige l’utilisateur vers le callback /auth/callback
  3. Cloud Run route le callback vers l’instance B
  4. L’instance B n’a pas le state → la validation OIDC échoue → l’utilisateur se retrouve dans une boucle de redirections

Ce problème disparaît si les sessions sont stockées dans un backend partagé — Redis, Memcached, ou un cookie signé côté client. Mais aucun de ces backends n’était provisionné dans ce POC.

La solution la plus simple pour Cloud Run : passer lua-resty-session en mode cookie HMAC. La session est stockée côté client dans un cookie chiffré et signé. Aucun état serveur, aucun problème de routage multi-instances. La contrepartie : la taille du cookie, et le fait que révoquer une session côté serveur devient impossible sans mécanisme additionnel.

La taille du cookie est un paramètre à surveiller. En mode cookie, le cookie contient les tokens JWT (jetons d’identité) eux-mêmes. Les navigateurs et certains proxies tronquent les cookies qui dépassent 4 KB — et les tokens Keycloak atteignent facilement cette limite si vous ajoutez des attributs personnalisés : métadonnées utilisateur, liste de rôles granulaires, claims d’organisation. Le symptôme est insidieux : des erreurs de session aléatoires, impossibles à reproduire en développement où les tokens sont plus courts.

Si vous avez besoin de stocker beaucoup de contexte dans vos tokens, la solution est de basculer vers un stockage de session côté serveur — Redis ou Memcached — avec uniquement un identifiant de session dans le cookie. La complexité opérationnelle augmente, mais la contrainte de taille disparaît.

3. Les bibliothèques Lua compilées dans une image Alpine

OpenResty sur Alpine Linux utilise musl libc plutôt que glibc. Certaines bibliothèques Lua installées via OPM ont des dépendances C compilées — et ces dépendances peuvent se comporter différemment selon la libc.

Dans notre cas, lua-resty-session en version 4.x avait des incompatibilités avec les autres bibliothèques de la stack dans cet environnement. Revenir à la version 3.8 (opm get bungle/lua-resty-session=3.8) a résolu les conflits — mais ce n’est pas une information qui apparaît clairement dans la documentation.

Ce n’est pas un problème majeur, mais c’est le type d’obstacle qui consomme une demi-journée quand on n’a pas encore identifié que c’est la couche libc qui cause des comportements inattendus.


En conclusion

Le pattern est bon — mais il n’est pas simple à opérer

Sortir l’auth du code applicatif est architecturalement sain. Ça centralise une responsabilité transverse, ça simplifie chaque service, ça permet de changer d’IdP sans toucher aux applications. Ce n’est pas un over-engineering — c’est une décision d’architecture que les organisations matures prennent délibérément.

Mais OpenResty n’est pas un outil clé en main pour ce cas d’usage. C’est un outil très flexible qui vous donne tous les blocs pour construire un auth gateway — à condition de savoir ce que vous faites. Il y a de la configuration nginx, du Lua, de la gestion de session, des appels OIDC. Chaque couche a ses subtilités.

Les environnements Cloud changent les hypothèses

En local, le DNS est simple, les instances sont uniques, la libc est celle de votre machine. Cloud Run change tout ça. DNS interne, scaling horizontal, Alpine dans le conteneur. Ces différences ne sont pas documentées dans les tutos OpenResty — qui sont écrits pour un nginx classique on-premise.

Avant de déployer un reverse proxy sur une plateforme managée, la question à poser n’est pas “est-ce que ça marche ?” mais “quelles hypothèses de mon environnement local ne tiennent plus dans ce contexte ?”

Il existe des alternatives plus adaptées à Cloud Run

Si votre contrainte principale est Cloud Run, Identity-Aware Proxy (IAP) de GCP fait exactement ce que nous essayions de faire — sans une ligne de Lua à maintenir. C’est managé, intégré à la gestion des identités GCP, et résout nativement les problèmes de scaling et de sessions.

La contrepartie : IAP suppose que votre IdP est Google. Si vous avez Keycloak, Azure AD, ou un IdP externe, IAP seul ne suffira pas et vous devrez le combiner avec Workforce Identity Federation ou une couche supplémentaire.

OAuth2 Proxy est une autre alternative — un outil dédié, moins flexible qu’OpenResty, mais conçu explicitement pour ce pattern et maintenu activement. Si vous n’avez pas besoin du routing nginx, c’est souvent le meilleur point de départ.


Quand est-ce que OpenResty a du sens ?

Après cette expérience, voici les critères qui, selon moi, justifient OpenResty comme auth gateway plutôt qu’une alternative :

OpenResty a du sens quand :

  • Vous avez déjà nginx dans la stack et une compétence Lua dans l’équipe.
  • Vous avez besoin de logique de routing complexe couplée à l’auth — filtrage par claims, réécriture conditionnelle d’URLs, règles métier dans le proxy.
  • Vous n’êtes pas sur une plateforme managée qui impose des contraintes réseau non documentées.
  • Vous avez un IdP non-Google que vous voulez intégrer sans couche supplémentaire.

Une alternative est probablement plus adaptée quand :

  • Vous êtes sur Cloud Run et votre IdP est géré par Google → IAP.
  • Vous voulez un auth gateway “out of the box” sans configuration nginx → OAuth2 Proxy.
  • Vous opérez Kubernetes → Envoy avec ext-authz, ou un service mesh avec mTLS.
  • Votre équipe n’a pas de compétence Lua, et personne ne maintiendra ce code dans 18 mois.

La suite

Ce retour d’expérience a une suite pratique. Après avoir compris pourquoi ça bloquait, on a construit une configuration qui fonctionne réellement — avec le bon resolver DNS, la gestion de session en cookie, le jeton interne pour sécuriser les communications entre services, et le bloc Lua d’authentification complet câblé dans nginx.conf.

Le prochain article couvre la mise en place pas à pas — de zéro à un auth gateway opérationnel sur une stack Next.js + Laravel, avec le code.


Guillaume P. — Engineering Manager | Cloud · Security · Identity

Newsletter

Un article par mois directement dans votre boîte mail.