sso : l'issuer et le nom d'hote se derivent de l'exposition declaree

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.<domaine>`), un nom que rien ne publie.
   Le plan expose `auth.<domaine>` — 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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-07 11:47:34 -04:00
parent 7cb358dab6
commit 249fba702a
6 changed files with 135 additions and 1 deletions

View file

@ -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.<domaine>` par convention — un
nom que rien ne publie. Le plan expose Keycloak sous `auth.<domaine>`, 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 :

View file

@ -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.

View file

@ -1,4 +1,25 @@
---
# Le nom d'hote de Keycloak EST son identite OIDC. Le fabriquer par convention
# (`keycloak.<domaine>`) 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:

View file

@ -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.<domaine>/.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

View file

@ -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://<app>/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: "*"

View file

@ -1,4 +1,29 @@
---
# L'ISSUER SE DERIVE DE L'EXPOSITION DECLAREE, il ne s'invente pas. Le defaut fabriquait
# `https://keycloak.<domaine>` par convention de nommage — un nom que rien ne publie : le
# plan expose Keycloak sous `auth.<domaine>`, 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 }}"