From 249fba702a573445d97cfd83831e3dda64930f67 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Fri, 7 Aug 2026 11:47:34 -0400 Subject: [PATCH] sso : l'issuer et le nom d'hote se derivent de l'exposition declaree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit oauth2-proxy fonctionne devant Icinga Web 2 : 302 vers Keycloak, qui federe LDAP. Quatre defauts leves, dont trois du meme motif — un nom construit par convention d'un cote, declare de l'autre. 1. Le secret de cookie etait en base64 STANDARD. oauth2-proxy decode en url-safe : le decodage echoue, il retombe sur la chaine brute et se plaint de sa LONGUEUR, jamais de l'encodage. Garde ajoutee qui refuse + et /. 2. L'issuer etait fabrique (`keycloak.`), un nom que rien ne publie. Le plan expose `auth.` — c'est ce nom que le DNS resout et que nginx sert. Derive desormais du champ `expose`. 3. Keycloak s'annoncait sous ce meme nom invente : « issuer did not match ». Meme correctif a la source. 4. Le pare-feu est-ouest bloquait l'edge : nginx ne declarait son 443 qu'avec `pair: externe`, que le devis est-ouest saute (il releve de la frontiere). Le flux existait d'un seul cote et la matrice etait satisfaite. nginx declare maintenant aussi son 443 depuis la flotte. obs-01 et mon-01 deployees. 7 cibles Prometheus up, scrutees en TLS a travers cinq zones du VRF. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 42 ++++++++++++++++++++ roles/serveur_keycloak/defaults/main.yml | 5 +++ roles/serveur_keycloak/tasks/main.yml | 21 ++++++++++ roles/serveur_nginx/meta/flux.yml | 16 ++++++++ roles/serveur_oauth2_proxy/defaults/main.yml | 14 ++++++- roles/serveur_oauth2_proxy/tasks/main.yml | 38 ++++++++++++++++++ 6 files changed, 135 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3999ca9..2daa094 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,48 @@ ## 2026-08-06 — le chemin nord-sud devient dérivable +### Le SSO fonctionne — quatre défauts, un seul motif + +`obs-01` et `mon-01` déployées : Loki, Prometheus, Grafana d'un côté ; Icinga, Icinga Web 2 et +oauth2-proxy de l'autre. **Sept VM debout**, et la supervision est réelle — 7 cibles Prometheus +*up*, une par hôte vivant, scrutées en TLS à travers cinq zones de sécurité du VRF. + +La preuve du SSO : + +``` +GET http://127.0.0.1:4180/ → 302 +https://auth.chezlepro.internal/realms/chezlepro/protocol/openid-connect/auth + ?client_id=icingaweb2&redirect_uri=https://icinga.chezlepro.internal/oauth2/callback +``` + +C'est le patron générique : Keycloak devant une application sans OIDC natif, fédérant LDAP. + +Il a fallu quatre corrections, et l'erreur changeait à chaque fois — signe qu'on avançait. + +**Le secret de cookie était en base64 standard.** oauth2-proxy décode en base64 *url-safe* ; un +secret contenant `+` ou `/` fait échouer le décodage, il retombe sur la chaîne brute et se +plaint de sa **longueur** — « is 44 bytes » — sans jamais mentionner l'encodage. Régénéré en +url-safe dans les deux tenants, avec une garde qui refuse `+` et `/` en nommant la cause. + +**L'issuer était inventé.** Le rôle fabriquait `https://keycloak.` par convention — un +nom que rien ne publie. Le plan expose Keycloak sous `auth.`, et c'est ce nom que +PowerDNS résout et que nginx sert. + +**Keycloak s'annonçait sous ce même nom inventé**, à la source : `issuer did not match the +issuer returned by provider`. Même correctif — le nom d'hôte se dérive de l'exposition. + +**Le pare-feu est-ouest bloquait l'edge.** Le flux existait d'un seul côté : `oauth2-proxy` +déclarait `egress 443 → edge`, la matrice était satisfaite (nginx déclare bien 443) — mais avec +`pair: externe`, que le devis est-ouest **saute volontairement**, puisqu'il relève de la +frontière. Aucune règle d'hyperviseur n'était émise, et la connexion expirait. `serveur_nginx` +déclare désormais aussi son 443 depuis la `flotte` : les FQDN publiés vivent à l'edge, et un +service interne qui appelle un autre service passe par son nom publié. + +**Trois de ces quatre sont le motif du jour** : un nom construit par convention d'un côté, +déclaré de l'autre. Le champ `expose` du plan a maintenant **cinq** consommateurs — PowerDNS, +nginx, `/etc/hosts`, oauth2-proxy et Keycloak — pour une seule source. + + ### Le supernet du tenant devient un intrant dérivé Keycloak ne démarrait pas : diff --git a/roles/serveur_keycloak/defaults/main.yml b/roles/serveur_keycloak/defaults/main.yml index d591153..cd5f8ad 100644 --- a/roles/serveur_keycloak/defaults/main.yml +++ b/roles/serveur_keycloak/defaults/main.yml @@ -9,6 +9,11 @@ serveur_keycloak_utilisateur: "keycloak" serveur_keycloak_service: "keycloak" # Publication (derriere serveur_nginx, TLS termine a l'edge). +# DERIVE de l'exposition que le plan declare (voir tasks/main.yml). Ce nom est celui que +# Keycloak ANNONCE dans son issuer OIDC : il doit etre exactement celui que l'edge sert et +# que le DNS resout, sinon tout client OIDC refuse le jeton — « issuer did not match ». +# La valeur ci-dessous n'est qu'un REPLI, si l'application n'expose rien. +serveur_keycloak_hostname_derive: true serveur_keycloak_hostname: "keycloak.{{ domaine_interne }}" # Base de donnees : resolue depuis le registre par le groupe consommateur. diff --git a/roles/serveur_keycloak/tasks/main.yml b/roles/serveur_keycloak/tasks/main.yml index fb32c84..ebb2670 100644 --- a/roles/serveur_keycloak/tasks/main.yml +++ b/roles/serveur_keycloak/tasks/main.yml @@ -1,4 +1,25 @@ --- + +# Le nom d'hote de Keycloak EST son identite OIDC. Le fabriquer par convention +# (`keycloak.`) produisait un nom que rien ne publie : le plan expose `auth`, +# l'edge sert `auth`, le DNS resout `auth` — et Keycloak s'annoncait `keycloak`. Tout +# client OIDC comparait les deux et refusait. Constate le 2026-08-07 sur oauth2-proxy : +# « issuer did not match the issuer returned by provider ». +- name: Charger les registres applications et domaines (expositions) + ansible.builtin.include_vars: + file: "{{ item }}" + loop: + - "{{ setops_plan_dir }}/applications.yml" + - "{{ setops_plan_dir }}/domaines.yml" + when: serveur_keycloak_hostname_derive | bool + +- name: Dériver le nom d'hôte public depuis l'exposition déclarée + ansible.builtin.set_fact: + serveur_keycloak_hostname: >- + {{ (applications | default({})).get('keycloak', {}).get('expose', []) | first }} + when: + - serveur_keycloak_hostname_derive | bool + - ((applications | default({})).get('keycloak', {}).get('expose', []) | length) > 0 - name: Exiger le mot de passe admin Keycloak (Vault) ansible.builtin.assert: that: diff --git a/roles/serveur_nginx/meta/flux.yml b/roles/serveur_nginx/meta/flux.yml index 8fcf87d..23809ff 100644 --- a/roles/serveur_nginx/meta/flux.yml +++ b/roles/serveur_nginx/meta/flux.yml @@ -15,6 +15,22 @@ flux: pair: externe chiffrement: tls-requis raison: "HTTPS entrant — services exposés (terminaison TLS à l'edge)." + # L'edge sert AUSSI les hôtes du tenant : les FQDN publiés (`expose`) n'existent qu'ici, + # et un service interne qui doit joindre un autre service passe par son nom publié — pas + # par une adresse. C'est le cas de toute découverte OIDC : oauth2-proxy interroge + # `https://auth./.well-known/…`, servi par l'edge. + # + # Sans cette déclaration, le flux existait d'un seul côté : `serveur_oauth2_proxy` + # déclarait `egress 443 → edge`, la matrice était satisfaite (nginx déclare bien 443), + # mais le devis est-ouest SAUTE `pair: externe` — il relève de la frontière. Aucune règle + # d'hyperviseur n'était donc émise, et oauth2-proxy expirait sur `10.27.16.11:443`. + # Constaté le 2026-08-07. + - sens: ingress + port: 443 + protocole: tcp + pair: flotte + chiffrement: tls-requis + raison: "HTTPS depuis le tenant : les FQDN publiés vivent à l'edge (découverte OIDC, appels inter-services par nom)." - sens: egress port: derive protocole: tcp diff --git a/roles/serveur_oauth2_proxy/defaults/main.yml b/roles/serveur_oauth2_proxy/defaults/main.yml index fcf61a2..6f34509 100644 --- a/roles/serveur_oauth2_proxy/defaults/main.yml +++ b/roles/serveur_oauth2_proxy/defaults/main.yml @@ -18,6 +18,11 @@ serveur_oauth2_proxy_ecoute: "0.0.0.0:4180" # OIDC (Keycloak) — realm applicatif. serveur_oauth2_proxy_realm: "{{ identite_realm | default('chezlepro') }}" +# DERIVE de l'exposition que le plan declare pour l'IdP (voir tasks/main.yml). La valeur +# ci-dessous n'est qu'un REPLI, utilise si l'application n'expose rien : un nom fabrique +# par convention n'est resolu par personne. +serveur_oauth2_proxy_issuer_derive: true +serveur_oauth2_proxy_app_idp: "keycloak" serveur_oauth2_proxy_issuer: "https://keycloak.{{ domaine_interne }}/realms/{{ serveur_oauth2_proxy_realm }}" serveur_oauth2_proxy_client_id: "" # OBLIGATOIRE (par instance) serveur_oauth2_proxy_client_secret: "" # OBLIGATOIRE (voûte) @@ -26,7 +31,14 @@ serveur_oauth2_proxy_redirect_url: "" # OBLIGATOIRE (https:///oauth2/ca # Application protégée (upstream local). serveur_oauth2_proxy_upstream: "" # OBLIGATOIRE (ex. http://127.0.0.1:8080) -# Secret de cookie (voûte) — 32 octets base64. +# Secret de cookie (voûte) — 32 octets en base64 URL-SAFE (alphabet `-_`, RFC 4648 §5). +# +# Le base64 STANDARD ne convient PAS, et l'erreur ne le dit pas : oauth2-proxy décode en +# `RawURLEncoding`, et un secret contenant `+` ou `/` fait échouer le décodage. Il retombe +# alors sur la chaîne BRUTE et se plaint de sa longueur — « is 44 bytes » — sans jamais +# mentionner l'encodage. Constaté le 2026-08-07 : le service redémarrait en boucle. +# +# Générer avec : `openssl rand -base64 32 | tr '+/' '-_'` serveur_oauth2_proxy_cookie_secret: "{{ vault_oauth2_cookie | default('') }}" # * = tout utilisateur authentifié par Keycloak. serveur_oauth2_proxy_email_domains: "*" diff --git a/roles/serveur_oauth2_proxy/tasks/main.yml b/roles/serveur_oauth2_proxy/tasks/main.yml index 8690e65..0f7c8e5 100644 --- a/roles/serveur_oauth2_proxy/tasks/main.yml +++ b/roles/serveur_oauth2_proxy/tasks/main.yml @@ -1,4 +1,29 @@ --- +# L'ISSUER SE DERIVE DE L'EXPOSITION DECLAREE, il ne s'invente pas. Le defaut fabriquait +# `https://keycloak.` par convention de nommage — un nom que rien ne publie : le +# plan expose Keycloak sous `auth.`, et c'est ce nom-la que PowerDNS resout et +# que nginx sert. Resultat le 2026-08-07 : `lookup keycloak.chezlepro.internal : no such +# host`, et oauth2-proxy redemarrait en boucle. +# +# Le champ `expose` est deja la source pour PowerDNS, nginx et /etc/hosts. C'est la +# quatrieme fois qu'on le lit : une convention de nommage se contredit, une declaration +# se corrige. +- name: Charger les registres applications et domaines (expositions) + ansible.builtin.include_vars: + file: "{{ item }}" + loop: + - "{{ setops_plan_dir }}/applications.yml" + - "{{ setops_plan_dir }}/domaines.yml" + when: serveur_oauth2_proxy_issuer_derive | bool + +- name: Dériver l'issuer depuis l'exposition de Keycloak + ansible.builtin.set_fact: + serveur_oauth2_proxy_issuer: >- + https://{{ (applications | default({})).get(serveur_oauth2_proxy_app_idp, {}).get('expose', []) | first }}/realms/{{ serveur_oauth2_proxy_realm }} + when: + - serveur_oauth2_proxy_issuer_derive | bool + - ((applications | default({})).get(serveur_oauth2_proxy_app_idp, {}).get('expose', []) | length) > 0 + - name: Exiger la configuration OIDC (client, secret, redirect, upstream, cookie) ansible.builtin.assert: that: @@ -9,6 +34,19 @@ - serveur_oauth2_proxy_cookie_secret | length > 0 fail_msg: "client_id, client_secret (voûte), redirect_url, upstream et cookie_secret (voûte) requis." +# Garde d'ENCODAGE, distincte de la garde de présence : un secret présent mais en base64 +# standard fait redémarrer oauth2-proxy en boucle, avec un message qui parle de longueur +# et jamais d'encodage. Mieux vaut refuser ici, où la cause est nommable. +- name: Refuser un cookie_secret qui n'est pas du base64 url-safe + ansible.builtin.assert: + that: + - "'+' not in serveur_oauth2_proxy_cookie_secret" + - "'/' not in serveur_oauth2_proxy_cookie_secret" + fail_msg: >- + `vault_oauth2_cookie` contient `+` ou `/` : c'est du base64 STANDARD. oauth2-proxy + décode en base64 URL-SAFE et retombera sur la chaîne brute, dont la longueur sera + refusée. Régénérer : openssl rand -base64 32 | tr '+/' '-_' + - name: Créer l'utilisateur système oauth2-proxy ansible.builtin.user: name: "{{ serveur_oauth2_proxy_utilisateur }}"