Keycloak × GeoNature : SSO OpenID Connect (Atelier du 30 septembre 2026)
Ce tutoriel couvre la mise en place de bout en bout : pourquoi un serveur d’identités,
l’installation de Keycloak sur un serveur, le paramétrage du realm, des clients et des
groupes, le contrôle d’accès applicatif, puis la configuration de GeoNature avec le provider
KeycloakOrganismProvider. Le support projeté en séance est embarqué en fin de page.
L’idée directrice
Keycloak répond à « qui es-tu ? » et « as-tu le droit d’entrer dans cette application ? ». GeoNature reste seul à répondre à « que peux-tu faire sur la donnée ? » : le CRUVED, les profils par application et la portée des données ne sont pas modifiés.
Note
Les sections « Paramétrer Keycloak », « Configurer GeoNature » et « Dépannage » reprennent
la documentation du dépôt GeoNature (docs/KEYCLOAK_GEONATURE.md,
DEPLOYMENT_KEYCLOAK.md), issue d’une implémentation réellement déployée. La section
« Installer Keycloak » décrit en revanche une installation Keycloak standard : elle est à
adapter à la politique d’infrastructure de votre établissement.
Support de présentation
Pourquoi Keycloak
UsersHub porte très bien le modèle de données métier (t_roles, bib_organismes,
cor_roles, profils par application) et l’authentification locale via pypnusershub.
En revanche il n’offre pas de SSO au-delà de l’écosystème PnX, pas de MFA, pas de
fédération LDAP / Active Directory / FranceConnect, pas d’OAuth2 exploitable par une
application mobile, ni délégation d’administration ou journal d’audit des sessions.
Keycloak (projet open source Red Hat / CNCF) est un serveur d’identités, pas une base d’utilisateurs de plus : il expose les identités via OpenID Connect, OAuth 2.0 et SAML 2.0, et les applications ne voient jamais le mot de passe.
Le vocabulaire
Objet Keycloak |
Ce que c’est |
Dans notre installation |
|---|---|---|
Realm |
Un espace d’identités étanche : ses utilisateurs, ses clés, ses politiques |
Un realm par système d’information |
Client |
Une application qui délègue son authentification au realm |
|
Client scope |
Un paquet de mappers, donc de claims, attaché à un client |
|
Mapper |
Une règle qui écrit un claim dans le token ou dans userinfo |
Le mapper Group Membership claim |
Group |
Un ensemble d’utilisateurs, hiérarchique, porteur d’attributs |
|
Role |
Une permission nommée, de realm ou de client |
Le rôle client |
Flow |
La suite d’étapes exécutées pendant une connexion |
|
Architecture cible
Du claim OIDC à la colonne PostgreSQL
Claim userinfo |
Destination GeoNature |
Remarque |
|---|---|---|
|
|
UUID stable de l’utilisateur Keycloak |
|
|
Réglé par |
|
|
Sert aussi de clé de réconciliation possible |
|
|
Champs obligatoires côté Keycloak |
|
|
Via le préfixe organisme et |
Attributs de groupe |
|
Lus par l’API admin, pas par le token |
Prérequis
Côté serveur
Élément |
Recommandation |
|---|---|
Système |
Debian 12 / Ubuntu 22.04 LTS ou plus récent, à jour |
CPU / RAM |
2 vCPU et 2 Go de RAM suffisent pour un realm de quelques centaines de comptes |
Java |
JDK 21 (Keycloak tourne sur Quarkus ; la version exacte requise est indiquée dans les notes de version de Keycloak) |
Base de données |
PostgreSQL 13+, distincte de celle de GeoNature (même serveur possible, base dédiée) |
Nom de domaine |
Un enregistrement DNS dédié, par exemple |
Certificat TLS |
Obligatoire : un code d’autorisation qui transite en clair est un compte compromis |
Avertissement
Keycloak doit être servi derrière un seul et unique hostname. Alterner entre
localhost et 127.0.0.1, ou entre deux noms de domaine, fait perdre les cookies de
session et provoque des erreurs MismatchingStateError et des boucles de connexion.
Côté GeoNature
Une instance GeoNature fonctionnelle, avec le sous-module
UsersHub-authentification-module(pypnusershub) à jour.Le fichier
backend/geonature/keycloak_provider.py(fourni intégralement plus bas).Une sauvegarde de la base de données avant toute mise en service : le provider écrit dans
utilisateurs.t_rolesetutilisateurs.bib_organismes.Un compte administrateur local GeoNature conservé pour l’accès de secours.
Installer Keycloak sur le serveur
Java et utilisateur système
sudo apt update
sudo apt install -y openjdk-21-jre-headless unzip
sudo useradd -r -m -d /opt/keycloak -s /sbin/nologin keycloak
Télécharger et déployer Keycloak
Récupérer la dernière version stable sur
la page des releases Keycloak et
l’installer dans /opt/keycloak :
KC_VERSION=26.0.7 # à remplacer par la version stable du moment
cd /tmp
wget https://github.com/keycloak/keycloak/releases/download/${KC_VERSION}/keycloak-${KC_VERSION}.zip
unzip keycloak-${KC_VERSION}.zip
sudo rm -rf /opt/keycloak && sudo mv keycloak-${KC_VERSION} /opt/keycloak
sudo chown -R keycloak:keycloak /opt/keycloak
sudo chmod o-rwx /opt/keycloak
Créer la base de données
sudo -u postgres psql <<'SQL'
CREATE USER keycloak WITH PASSWORD 'mot_de_passe_solide';
CREATE DATABASE keycloak OWNER keycloak;
SQL
Configurer keycloak.conf
Fichier /opt/keycloak/conf/keycloak.conf :
# Base de données
db=postgres
db-url=jdbc:postgresql://localhost:5432/keycloak
db-username=keycloak
db-password=mot_de_passe_solide
# Hostname public, servi derrière un reverse proxy TLS
hostname=https://keycloak.mon-domaine.fr
proxy-headers=xforwarded
http-enabled=true
http-host=127.0.0.1
http-port=8080
# Indispensable au script authenticator « Client Access Guard »
features=preview,scripts
Important
La ligne features=preview,scripts n’est pas optionnelle : sans elle, le script
authenticator décrit plus loin n’apparaîtra jamais dans la liste des exécutions du browser
flow. Les noms d’options de hostname et de proxy ont changé entre les versions majeures de
Keycloak — vérifier la syntaxe dans la documentation de la version installée.
Construire l’image optimisée
Toute modification de features, du moteur de base de données ou du contenu de
/opt/keycloak/providers/ impose de rejouer le build :
sudo -u keycloak /opt/keycloak/bin/kc.sh build
Avertissement
Sans build, un JAR déposé dans providers/ n’est pas chargé et Keycloak loggue
seulement A provider JAR was updated since the last build, please rebuild. C’est la
cause numéro un des « le script n’apparaît pas dans la liste ».
Service systemd
Fichier /etc/systemd/system/keycloak.service :
[Unit]
Description=Keycloak Identity Provider
After=network.target postgresql.service
[Service]
User=keycloak
Group=keycloak
ExecStart=/opt/keycloak/bin/kc.sh start --optimized
Restart=on-failure
RestartSec=5
LimitNOFILE=102642
[Install]
WantedBy=multi-user.target
Créer le compte administrateur initial, puis démarrer le service :
# Compte d'amorçage, uniquement pour le premier démarrage
sudo -u keycloak KC_BOOTSTRAP_ADMIN_USERNAME=admin \
KC_BOOTSTRAP_ADMIN_PASSWORD='mot_de_passe_temporaire' \
/opt/keycloak/bin/kc.sh start --optimized &
sudo systemctl daemon-reload
sudo systemctl enable --now keycloak
sudo systemctl status keycloak
Note
Le nom des variables d’amorçage de l’administrateur a changé selon les versions
(KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD puis KC_BOOTSTRAP_ADMIN_*). Se
connecter ensuite à la console d’administration pour créer un compte nominatif, et
supprimer le compte d’amorçage.
Reverse proxy et TLS
Exemple pour nginx, avec un certificat Let’s Encrypt :
server {
listen 443 ssl http2;
server_name keycloak.mon-domaine.fr;
ssl_certificate /etc/letsencrypt/live/keycloak.mon-domaine.fr/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/keycloak.mon-domaine.fr/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
}
}
server {
listen 80;
server_name keycloak.mon-domaine.fr;
return 301 https://$host$request_uri;
}
Avertissement
Sans X-Forwarded-Proto, Keycloak génère des URLs de redirection en http:// et le
flux OIDC casse. C’est le pendant de l’option proxy-headers=xforwarded côté
keycloak.conf.
Vérifier l’installation
# La découverte OIDC doit répondre en JSON
curl -s https://keycloak.mon-domaine.fr/realms/master/.well-known/openid-configuration | jq .issuer
# Les features preview doivent être listées au démarrage
sudo journalctl -u keycloak | grep -i "Preview features enabled"
La seconde commande doit mentionner scripts.
Paramétrer le realm
Créer le realm
Realm settings → Create realm → nom si-rnf (exemple). L’URL du realm est l”ISSUER
que GeoNature mettra dans sa configuration :
https://keycloak.mon-domaine.fr/realms/si-rnf
Realm settings → General → Endpoints → OpenID Endpoint Configuration donne tous les endpoints dérivés.
Les réglages qui comptent
Onglet |
Paramètre |
Valeur recommandée |
|---|---|---|
Login |
User registration |
OFF en production, ON en développement |
Login |
Forgot password |
ON (nécessite le SMTP configuré) |
Login |
Login with email |
ON |
Login |
Edit username |
OFF, pour garder un |
Login |
Duplicate emails / Remember me |
OFF |
Tokens |
Default Signature Algorithm |
RS256 |
Tokens |
Access Token Lifespan |
5 à 15 minutes |
Tokens |
Revoke Refresh Token |
ON |
Sessions |
SSO Session Idle / Max |
30 minutes / 10 heures |
Localization |
Internationalization |
ON, locale par défaut |
Security defenses |
Brute force detection |
ON |
Themes |
Login theme |
|
Les durées de token n’ont aucun effet sur la présence du claim groups : elles ne pilotent
que la validité des jetons.
Créer les groupes et porter l’organisme en attributs
L’arborescence
Groups → Create group, les parents d’abord :
/applications
/geonature-local # droit d'entrée sur GeoNature web
/occtax-mobile # droit d'entrée sur l'application mobile
/organismes
/rn-test # organisme de rattachement + attributs
/reserves
/42 # contexte métier réserve (usages à venir)
/geonature
/test
/Grp_admin # clés de group_mapping vers les groupes GeoNature
Branche |
Intention |
|---|---|
|
Droit d’entrée. Le groupe porte le rôle client |
|
Organisme de rattachement. Porte les attributs lus par le provider. |
|
Contexte métier réserve, réservé aux usages à venir. |
|
Clés de |
Cette séparation n’est pas cosmétique : le mapper Group Membership natif n’offre aucun filtre par préfixe, tous les groupes de l’utilisateur partent dans le claim. C’est le préfixe du chemin qui permet ensuite de trier.
Les attributs des groupes organisme
Groups → /organismes/rn-test → onglet Attributes :
Clé exacte |
Exemple de valeur |
Usage dans GeoNature |
|---|---|---|
|
|
Clé primaire reprise telle quelle si l’organisme n’existe pas encore |
|
|
Identifiant pivot entre instances |
|
|
Libellé ; à défaut le nom du groupe est utilisé |
Important
Dans Keycloak, un attribut de groupe est toujours une liste de chaînes : le provider lit
attributes["id_organisme"][0] et convertit lui-même en entier. Ces attributs ne sont
jamais exposés dans le token de l’utilisateur ; seule l’API d’administration les rend
lisibles, d’où le client geonature-sync créé plus loin.
Exposer les groupes dans userinfo
Les client scopes
Keycloak assemble les tokens à partir des client scopes assignés au client, et chaque scope contient des mappers. Un claim manquant, c’est presque toujours un scope non assigné ou un mapper absent.
Client scope (en Default) |
Ce qu’il apporte |
|---|---|
|
|
|
|
|
|
|
|
|
en-têtes CORS, pas de claim |
Avertissement
Un scope en Optional n’est appliqué que si le client le demande explicitement. GeoNature
demande openid email profile et rien d’autre — c’est codé en dur dans
OpenIDProvider.configure(). Tout scope utile doit donc être en Default.
Le mapper Group Membership
C’est le paramétrage le plus important de toute la chaîne.
Client scopes → roles → Mappers → Add mapper → By configuration → Group Membership
Champ |
Valeur |
Pourquoi |
|---|---|---|
Name |
|
Libellé interne du mapper |
Token Claim Name |
|
Doit correspondre à |
Full group path |
ON |
Donne |
Add to userinfo |
ON |
Obligatoire : c’est la seule source lue par GeoNature |
Add to ID token |
ON |
Recommandé, pour déboguer |
Add to access token |
ON |
Recommandé, pour déboguer |
Add to token introspection |
ON |
Si l’option existe dans votre version |
Important
GeoNature lit les groupes dans userinfo, pas dans le JWT. L’access token et l’ID
token sont utiles au débogage mais ignorés par le code, tout comme realm_access.roles et
resource_access. Si Add to userinfo est désactivé, le token contient bien les groupes,
l’organisme n’est jamais résolu, et seul un warning apparaît dans les logs du backend.
Une variante consiste à créer un client scope dédié groups et à l’assigner en Default au
client. Poser le mapper sur le scope roles est plus simple, car ce scope est déjà assigné
par défaut à tous les clients.
Vérifier avant d’aller plus loin
ISSUER=https://keycloak.mon-domaine.fr/realms/si-rnf
curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
"$ISSUER/protocol/openid-connect/userinfo" | jq .
{
"sub": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"email_verified": true,
"name": "Jean Dupont",
"preferred_username": "jdupont",
"given_name": "Jean",
"family_name": "Dupont",
"email": "jean.dupont@example.org",
"groups": [
"/applications/geonature-local",
"/organismes/rn-test",
"/geonature/test/Grp_admin"
]
}
Les quatre points à cocher :
groupsest présent dans userinfo, pas seulement dans le JWT ;les valeurs sont en chemin complet, commençant par
/;tous les groupes attendus de l’utilisateur sont listés ;
preferred_username,email,given_nameetfamily_namesont renseignés.
Créer les trois clients
Client |
Usage |
|---|---|
|
Connexion des utilisateurs web. Confidential, standard flow, secret côté serveur. Porte
le rôle |
|
Lecture technique des groupes via l’API admin. Service account, sans flow navigateur. |
|
Application mobile. Public, PKCE obligatoire, redirection sur schéma natif, aucun secret. |
Note
Pourquoi séparer geonature-sync ? Parce que lire l’API d’administration demande des
droits que l’on ne veut jamais donner au client qui authentifie les utilisateurs. Un secret
compromis côté web ne donne alors aucun accès à l’annuaire.
Le client web geonature-local
Clients → Create client → OpenID Connect, Client ID geonature-local.
Capability config
Paramètre |
Valeur |
Notes |
|---|---|---|
Client authentication |
ON |
Client confidential, donc |
Standard flow |
ON |
Authorization Code Flow |
Direct access grants |
OFF |
Pas de login/mot de passe direct sur |
Implicit flow |
OFF |
Déprécié |
Service accounts roles |
OFF |
Réservé à |
Device Authorization Grant / CIBA |
OFF |
Settings
Champ |
Valeur (exemple local) |
|---|---|
Root URL / Home URL |
|
Valid redirect URIs |
|
Valid post logout redirect URIs |
|
Web origins |
|
Admin URL |
vide |
Important
La redirect URI se déduit mécaniquement de la configuration GeoNature :
{API_ENDPOINT}/auth/authorize/{id_provider}. Avec API_ENDPOINT = 'http://localhost:8000/api'
et id_provider = "keycloak", cela donne
http://localhost:8000/api/auth/authorize/keycloak. Pas de joker approximatif : une URI
trop large est une faille, une URI fausse donne Invalid parameter: redirect_uri.
Advanced : signature des tokens en RS256, Proof Key for Code Exchange Code Challenge
Method = S256, Always use PKCE = OFF pour un client confidential (GeoNature envoie de
toute façon le challenge, et CODE_CHALLENGE_METHOD du TOML doit valoir S256).
Credentials : Client Authenticator = Client Id and Secret ; recopier le secret dans
CLIENT_SECRET. Tout secret utilisé pendant la mise au point doit être régénéré avant
l’ouverture aux utilisateurs.
Roles : créer un rôle client nommé access.
Requête réellement émise par GeoNature au début du flow :
GET {ISSUER}/protocol/openid-connect/auth
?response_type=code
&client_id=geonature-local
&redirect_uri=http://localhost:8000/api/auth/authorize/keycloak
&scope=openid+email+profile
&state=…
&code_challenge=…
&code_challenge_method=S256
Le client service geonature-sync
Paramètre |
Valeur |
|---|---|
Client authentication |
ON |
Service accounts roles |
ON |
Standard flow / Direct access grants |
OFF |
Valid redirect URIs / Web origins |
vides |
Service account roles → filtrer sur realm-management → assigner le rôle query-groups.
C’est le droit minimum, et il suffit (view-users peut être ajouté pour du débogage).
Ce que GeoNature appelle avec ce client :
# 1 · jeton technique
POST {ISSUER}/protocol/openid-connect/token
grant_type=client_credentials&client_id=geonature-sync&client_secret=…
# 2 · lecture du groupe, avec repli sur la recherche
GET {HOST}/admin/realms/{REALM}/group-by-path/organismes/rn-test
GET {HOST}/admin/realms/{REALM}/groups?search=rn-test&briefRepresentation=false&max=200
L’URL de base admin est dérivée de l”ISSUER en remplaçant /realms/ par
/admin/realms/.
Avertissement
Sans query-groups, la lecture échoue silencieusement côté utilisateur : la connexion
réussit, l’organisme n’est pas résolu, et seul un warning apparaît dans les logs du backend.
Le client mobile occtax-mobile
Paramètre |
Valeur |
|---|---|
Client authentication |
OFF (client public) |
Standard flow |
ON |
Direct access grants |
OFF |
Valid redirect URIs |
|
Web origins |
vide (application native) |
Advanced → Always use PKCE |
ON |
Aucun CLIENT_SECRET côté mobile. Les client scopes sont les mêmes que pour le web. Si le
script de contrôle d’accès s’applique aussi au mobile, créer le rôle access sur ce client
et mapper le groupe /applications/occtax-mobile dessus.
Créer les utilisateurs
Users → Add user.
Champ Keycloak |
Colonne GeoNature |
Obligatoire |
|---|---|---|
Username |
|
oui |
|
oui |
|
First name |
|
oui |
Last name |
|
oui |
Enabled |
condition de connexion |
oui |
Avertissement
Un prénom ou un nom vide fait échouer la création du rôle GeoNature : le provider lit
user_info["given_name"] sans valeur de repli.
Onglet Groups → Join Group : rattacher l’utilisateur à au moins
/applications/geonature-local et à son groupe /organismes/…. Onglet Credentials :
définir un mot de passe, Temporary = OFF pour un compte de test.
Contrôler qui a le droit d’entrer
Le realm authentifie tout le monde, mais GeoNature ne concerne que certains. Sans garde-fou,
n’importe quel compte du realm se connecte : le provider crée un t_roles, l’associe à un
organisme et au groupe de réconciliation par défaut, et l’on se retrouve avec des comptes
GeoNature créés par accident, à nettoyer à la main.
Avec le rôle access, Keycloak refuse la connexion avant de délivrer le code : GeoNature
n’est jamais appelé, aucune ligne n’est créée en base. Le même mécanisme protège toutes les
applications du realm, avec un rôle access par client.
Câbler le droit d’entrée
Créer le rôle : Clients →
geonature-local→ Roles → Create role →accessCréer le groupe : Groups → Create group →
/applications/geonature-localLier les deux : Groups → le groupe → Role mapping → Assign role, filtrer par client →
geonature-local:access
Keycloak n’a pas de case « autoriser tout le monde sur ce client ». Trois approches possibles :
un groupe parent /applications mappé vers access, un rôle de realm composite, ou un
script assoupli qui ignore certains clients. La recommandation retenue est le groupe
/applications/<client-id> avec ajout explicite des utilisateurs.
Note
Rien de tout cela n’est appliqué tant que le script ci-dessous n’est pas branché dans le browser flow : le rôle existe, il est attribué, mais aucune étape de connexion ne le vérifie.
Le script authenticator, livré comme un JAR
kc-script-auth/
├── META-INF/
│ └── keycloak-scripts.json
└── client-access-guard.js
META-INF/keycloak-scripts.json :
{
"authenticators": [
{
"name": "Client Access Guard",
"fileName": "client-access-guard.js",
"description": "Require client role 'access' on current client"
}
]
}
Le champ name est celui qui apparaîtra dans Authentication → Flows → Add execution.
S’il n’y apparaît pas, c’est que la feature scripts est absente ou que le build n’a pas été
rejoué.
client-access-guard.js :
var AuthenticationFlowError = Java.type("org.keycloak.authentication.AuthenticationFlowError");
var REQUIRED_ROLE = "access";
var EXCLUDED_CLIENTS = {
"account-console": true,
"security-admin-console": true,
"admin-cli": true,
"broker": true
};
function authenticate(context) {
var user = context.getUser();
if (user == null) {
context.attempted();
return;
}
var authSession = context.getAuthenticationSession();
var client = authSession != null ? authSession.getClient() : null;
if (client == null) {
context.success();
return;
}
var clientId = String(client.getClientId());
if (EXCLUDED_CLIENTS[clientId]) {
context.success();
return;
}
var role = client.getRole(REQUIRED_ROLE);
if (role != null && user.hasRole(role)) {
context.success();
return;
}
context.getEvent().error("client_role_missing");
var challenge = context.form().setError("invalidUserMessage").createLoginUsernamePassword();
context.failureChallenge(AuthenticationFlowError.INVALID_USER, challenge);
}
function action(context) {
context.success();
}
function requiresUser() {
return true;
}
function configuredFor(session, realm, user) {
return true;
}
function setRequiredActions(session, realm, user) {
}
function close() {
}
Trois points méritent l’attention :
user.hasRole()interroge le moteur de Keycloak, pas le JWT : les rôles hérités des groupes comptent.Le fichier ne doit exposer que des fonctions ; un
returnau niveau global donne uneScriptCompilationException: Invalid return statement.Le message d’erreur est volontairement générique : ne jamais révéler que le compte existe mais n’a pas le rôle.
Packaging et déploiement :
cd ~/kc-script-auth
jar cf client-access-guard.jar META-INF client-access-guard.js
jar tf client-access-guard.jar # vérifier le contenu
sudo cp client-access-guard.jar /opt/keycloak/providers/
sudo /opt/keycloak/bin/kc.sh build
sudo systemctl restart keycloak
Brancher le script dans le browser flow
Authentication → Flows : dupliquer le flow
browserenbrowser-rnf.Dans le sous-flow
forms, ordonner les exécutions :Exécution
Requirement
Username Password Form
REQUIRED
Client Access Guard
REQUIRED
Browser - Conditional 2FA
CONDITIONAL
Authentication → Bindings → Browser Flow =
browser-rnf.
Le message affiché à l’utilisateur refusé se règle dans le thème de login, fichier
messages/messages_fr.properties :
invalidUserMessage=Nom d'utilisateur ou mot de passe invalide ou accès non autorisé
loginTitle=Se connecter au {0}
loginTitleHtml=Se connecter au {0}
Un seul message pour trois situations — mauvais mot de passe, compte inconnu, accès refusé. C’est volontaire : on ne donne aucune information exploitable.
Personnaliser la page de connexion
Structure du thème
/opt/keycloak/themes/rnf/
└── login/
├── theme.properties
├── resources/
│ ├── css/
│ │ └── styles.css
│ └── img/
│ └── logo.png
├── messages/
│ └── messages_fr.properties
└── footer.ftl # optionnel (lien inscription)
theme.properties :
parent=keycloak.v2
styles=css/styles.css
Avertissement
La clé styles remplace la liste des CSS du parent, elle ne s’y ajoute pas. Ne pas y
référencer css/login.css si ce fichier n’existe pas dans votre thème : cela provoque un
404.
styles.css
/* Fond vert institutionnel */
body.kcBodyClass,
.pf-v5-c-login {
background: #00885b !important;
}
/* Logo (remplace le texte du header) */
#kc-header-wrapper.pf-v5-c-brand {
display: block;
width: 340px;
height: 120px;
margin: 0 auto;
background: url("../img/logo.png") no-repeat center center;
background-size: contain;
}
#kc-header-wrapper .kc-logo-text,
#kc-header-wrapper .kc-logo-text span {
display: none !important;
}
/* Bouton connexion */
#kc-login.pf-v5-c-button.pf-m-primary {
background-color: #00885b !important;
border-color: #00885b !important;
color: #fff !important;
}
#kc-login.pf-v5-c-button.pf-m-primary:hover,
#kc-login.pf-v5-c-button.pf-m-primary:focus,
#kc-login.pf-v5-c-button.pf-m-primary:active {
background-color: #006f4a !important;
border-color: #006f4a !important;
}
/* Layout : logo au-dessus du formulaire */
.pf-v5-c-login__container {
display: grid;
grid-template-areas:
"header"
"main" !important;
grid-template-columns: 1fr !important;
justify-items: center;
}
.pf-v5-c-login__header {
grid-area: header;
margin-bottom: 1rem;
}
.pf-v5-c-login__main {
grid-area: main;
}
Les classes pf-v5-* viennent de PatternFly 5, le socle graphique de keycloak.v2 : elles
changent d’une version majeure de Keycloak à l’autre, à revalider lors des montées de version.
Lien d’inscription optionnel, footer.ftl :
<#macro content>
<div style="margin-top: 1rem; text-align: center;">
<a href="https://votre-domaine.fr/inscription">
Créer un compte
</a>
</div>
</#macro>
Activation : Realm settings → Themes → Login theme = rnf. Le lien « mot de passe
oublié » s’active dans Realm settings → Login → Forgot password, avec le SMTP configuré.
Les pièges rencontrés
Problème |
Cause et solution |
|---|---|
CSS modifié mais pas appliqué |
Cache gzip de Keycloak : |
|
Cache navigateur ; hard refresh, ou renommer le fichier en |
Logo invisible |
Mauvais sélecteur : viser |
|
|
404 sur |
Fichier référencé dans |
Configurer GeoNature
Les URLs
Dans config/geonature_config.toml, elles doivent être cohérentes avec ce qui a été déclaré
côté Keycloak (redirect URI, web origins) :
URL_APPLICATION = 'http://localhost:4200'
API_ENDPOINT = 'http://localhost:8000/api'
Le bloc [AUTHENTICATION]
[AUTHENTICATION]
DEFAULT_RECONCILIATION_GROUP_ID = 1
# Toujours conserver un accès local
[[AUTHENTICATION.PROVIDERS]]
module = "pypnusershub.auth.providers.default.LocalProvider"
id_provider = "local_provider"
[[AUTHENTICATION.PROVIDERS]]
module = "geonature.keycloak_provider.KeycloakOrganismProvider"
id_provider = "keycloak"
ISSUER = "https://keycloak.mon-domaine.fr/realms/si-rnf"
CLIENT_ID = "geonature-local"
CLIENT_SECRET = "<secret_client_geonature>"
# Hérités de OpenIDConnectProvider
group_claim_name = "groups"
IDENTIFIER_FIELD = "preferred_username"
RECONCILIATE_ATTR = "email"
CODE_CHALLENGE_METHOD = "S256"
# Propres au provider custom
ORGANISM_GROUP_PREFIX = "/organismes/"
ORGANISM_UUID_CLAIM = "uuid_organisme"
ORGANISM_NAME_CLAIM = "organisme"
USER_UUID_CLAIM = "sub"
KEYCLOAK_ADMIN_CLIENT_ID = "geonature-sync"
KEYCLOAK_ADMIN_CLIENT_SECRET = "<secret_client_geonature_sync>"
KEYCLOAK_ADMIN_TIMEOUT = 5
[AUTHENTICATION.PROVIDERS.group_mapping]
"/geonature/test" = 1
"/geonature/test/Grp_admin" = 2
Clé |
Effet |
|---|---|
|
URL du realm, sans |
|
Client OIDC web confidential |
|
Nom du claim contenant les chemins de groupes |
|
Claim utilisé comme identifiant GeoNature |
|
Champ de rapprochement avec un compte existant, côté classe parente |
|
Préfixe qui désigne un groupe organisme parmi tous les groupes reçus |
|
Claim recopié dans |
|
Identifiants du service account et délai d’expiration des appels admin |
|
Chemin de groupe Keycloak → |
|
Groupe attribué quand aucun mapping ne s’applique |
Avertissement
Garder local_provider déclaré. Sans lui, le frontend redirige automatiquement vers
Keycloak, et la moindre erreur de configuration produit une boucle de redirection dont on ne
sort plus par l’interface. C’est aussi l’accès de secours si Keycloak tombe.
Redémarrer le backend, puis vérifier que les deux providers sont exposés :
sudo systemctl restart geonature # selon votre méthode de déploiement
curl -s http://localhost:8000/api/auth/providers | jq .
Le provider KeycloakOrganismProvider en entier
Fichier backend/geonature/keycloak_provider.py. Il hérite de OpenIDConnectProvider
(module pypnusershub) et ajoute quatre choses :
la résolution de l’organisme depuis le groupe
/organismes/…et ses attributs, lus via l’API admin ;la création ou la mise à jour de
bib_organismes, puis le renseignement det_roles.id_organisme;la recopie du claim
subdanst_roles.uuid_role, après validation du format UUID ;la réconciliation forcée sur
identifiantplutôt que sur l’email.
Ce qu’il ne fait pas : aucun contrôle d’accès applicatif (c’est le rôle de Keycloak, via le
script Client Access Guard), et aucune synchronisation miroir des groupes à chaque
reconnexion.
La cascade de résolution de l’organisme est à retenir : id_organisme (reprise d’un
identifiant existant), puis uuid_organisme (pivot entre instances), puis nom_organisme
(dernier recours), et enfin création d’un nouvel organisme. L’appel nominal à l’API admin est
group-by-path ; en cas d’échec, le code se replie sur groups?search=<feuille> puis
parcourt récursivement les sous-groupes jusqu’à retrouver le chemin exact.
from typing import Any, Optional, Union
from urllib.parse import quote
import time
import uuid
import requests
import sqlalchemy as sa
from flask import current_app, session
from marshmallow import EXCLUDE, ValidationError, fields
from pypnusershub.auth import ProviderConfigurationSchema, oauth
from pypnusershub.auth.providers.openid_provider import OpenIDConnectProvider
from pypnusershub.db import db, models
class KeycloakOrganismProvider(OpenIDConnectProvider):
"""
OpenID Connect provider with automatic organism reconciliation.
Expected token/userinfo claims:
- groups (list[str]) with entries like "/organismes/<slug-or-name>"
- optionally a claim containing organism UUID (default: "uuid_organisme")
- optionally a claim containing organism label (default: "organisme")
"""
group_prefix = "/organismes/"
organism_uuid_claim = "uuid_organisme"
organism_name_claim = "organisme"
user_uuid_claim = "sub"
keycloak_issuer = None
keycloak_admin_client_id = None
keycloak_admin_client_secret = None
keycloak_admin_timeout = 5
_kc_admin_token = None
_kc_admin_token_exp = 0
def configure(self, configuration: Union[dict, Any]) -> None:
super().configure(configuration)
class KeycloakOrganismConfiguration(ProviderConfigurationSchema):
ORGANISM_GROUP_PREFIX = fields.String(load_default="/organismes/")
ORGANISM_UUID_CLAIM = fields.String(load_default="uuid_organisme")
ORGANISM_NAME_CLAIM = fields.String(load_default="organisme")
USER_UUID_CLAIM = fields.String(load_default="sub")
KEYCLOAK_ADMIN_CLIENT_ID = fields.String(load_default=None, allow_none=True)
KEYCLOAK_ADMIN_CLIENT_SECRET = fields.String(load_default=None, allow_none=True)
KEYCLOAK_ADMIN_TIMEOUT = fields.Integer(load_default=5)
try:
conf = KeycloakOrganismConfiguration().load(configuration, unknown=EXCLUDE)
except ValidationError as e:
raise ValidationError(f"Error while loading Keycloak organism configuration: {e}")
self.group_prefix = conf["ORGANISM_GROUP_PREFIX"]
self.organism_uuid_claim = conf["ORGANISM_UUID_CLAIM"]
self.organism_name_claim = conf["ORGANISM_NAME_CLAIM"]
self.user_uuid_claim = conf["USER_UUID_CLAIM"]
self.keycloak_admin_client_id = conf["KEYCLOAK_ADMIN_CLIENT_ID"]
self.keycloak_admin_client_secret = conf["KEYCLOAK_ADMIN_CLIENT_SECRET"]
self.keycloak_admin_timeout = conf["KEYCLOAK_ADMIN_TIMEOUT"]
# ISSUER is required by OpenIDConnectProvider, keep it for admin API calls.
self.keycloak_issuer = configuration.get("ISSUER")
def _extract_first_group_organism_path(self, groups):
if not groups:
return None
for group in groups:
if isinstance(group, str) and group.startswith(self.group_prefix):
return group
return None
def _extract_first_group_organism_name(self, groups):
group_path = self._extract_first_group_organism_path(groups)
if not group_path:
return None
# Keep leaf name only: /organismes/foo/bar -> bar
return group_path.rstrip("/").split("/")[-1] or None
def _get_kc_admin_base(self) -> Optional[str]:
if not self.keycloak_issuer or "/realms/" not in self.keycloak_issuer:
return None
host, realm = self.keycloak_issuer.split("/realms/", 1)
return f"{host}/admin/realms/{realm}"
def _get_kc_admin_token(self) -> Optional[str]:
if not (
self.keycloak_issuer
and self.keycloak_admin_client_id
and self.keycloak_admin_client_secret
):
return None
if self._kc_admin_token and time.time() < self._kc_admin_token_exp:
return self._kc_admin_token
token_url = f"{self.keycloak_issuer}/protocol/openid-connect/token"
resp = requests.post(
token_url,
data={
"grant_type": "client_credentials",
"client_id": self.keycloak_admin_client_id,
"client_secret": self.keycloak_admin_client_secret,
},
timeout=self.keycloak_admin_timeout,
)
if not resp.ok:
current_app.logger.warning(
"Keycloak admin token request failed: %s - %s",
resp.status_code,
resp.text[:200],
)
return None
payload = resp.json()
self._kc_admin_token = payload.get("access_token")
self._kc_admin_token_exp = time.time() + max(payload.get("expires_in", 60) - 10, 10)
return self._kc_admin_token
def _get_group_attributes_from_keycloak(self, group_path):
admin_base = self._get_kc_admin_base()
admin_token = self._get_kc_admin_token()
if not admin_base or not admin_token or not group_path:
return None
# Some Keycloak setups expect "/" to remain unescaped in group-by-path.
url = f"{admin_base}/group-by-path/{quote(group_path, safe='/')}"
resp = requests.get(
url,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=self.keycloak_admin_timeout,
)
if resp.ok:
return resp.json()
# Fallback: use search endpoint then match exact path recursively.
leaf_name = group_path.rstrip("/").split("/")[-1]
search_url = (
f"{admin_base}/groups?search={quote(leaf_name, safe='')}"
"&briefRepresentation=false&max=200"
)
search_resp = requests.get(
search_url,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=self.keycloak_admin_timeout,
)
if search_resp.ok:
groups = search_resp.json()
def walk(items):
for item in items or []:
if item.get("path") == group_path:
return item
found = walk(item.get("subGroups") or [])
if found:
return found
return None
found_group = walk(groups)
if found_group:
return found_group
# Keep warning logs for diagnostics.
if not resp.ok:
current_app.logger.warning(
"Keycloak group-by-path failed for %s: %s - %s",
group_path,
resp.status_code,
resp.text[:200],
)
if "search_resp" in locals() and not search_resp.ok:
current_app.logger.warning(
"Keycloak groups search failed for %s: %s - %s",
leaf_name,
search_resp.status_code,
search_resp.text[:200],
)
return None
def _resolve_organism(self, user_info, source_groups):
org_id = None
org_uuid = user_info.get(self.organism_uuid_claim)
org_name = user_info.get(self.organism_name_claim)
if not org_uuid or not org_name:
group_path = self._extract_first_group_organism_path(source_groups)
group_obj = self._get_group_attributes_from_keycloak(group_path)
if group_obj:
group_attrs = group_obj.get("attributes") or {}
id_values = group_attrs.get("id_organisme") or []
if id_values:
try:
org_id = int(id_values[0])
except (TypeError, ValueError):
current_app.logger.warning(
"Invalid id_organisme value on group %s: %s",
group_obj.get("path"),
id_values[0],
)
if not org_uuid:
uuid_values = group_attrs.get("uuid_organisme") or []
if uuid_values:
org_uuid = uuid_values[0]
if not org_name:
name_values = group_attrs.get("nom_organisme") or []
org_name = name_values[0] if name_values else group_obj.get("name")
# Fallback to group leaf if no nom_organisme is provided.
org_name = org_name or self._extract_first_group_organism_name(source_groups)
# Nothing to reconcile.
if not org_uuid and not org_name:
return None
organism = None
if org_id:
organism = db.session.execute(
sa.select(models.Organisme).where(models.Organisme.id_organisme == org_id)
).scalar_one_or_none()
if org_uuid:
organism_by_uuid = db.session.execute(
sa.select(models.Organisme).where(models.Organisme.uuid_organisme == org_uuid)
).scalar_one_or_none()
if (
organism
and organism_by_uuid
and organism.id_organisme != organism_by_uuid.id_organisme
):
current_app.logger.warning(
"Organism mismatch between id_organisme=%s and uuid_organisme=%s",
org_id,
org_uuid,
)
if not organism:
organism = organism_by_uuid
if not organism and org_name:
organism = db.session.execute(
sa.select(models.Organisme).where(models.Organisme.nom_organisme == org_name)
).scalar_one_or_none()
if not organism:
organism = models.Organisme(nom_organisme=org_name or str(org_uuid))
if org_id:
# Keep upstream identifier when available (migration-friendly).
organism.id_organisme = org_id
if org_uuid:
organism.uuid_organisme = org_uuid
db.session.add(organism)
db.session.flush()
return organism
updated = False
if org_id and organism.id_organisme != org_id:
# id_organisme is the local PK, do not overwrite an existing row identity.
current_app.logger.warning(
"Ignoring id_organisme=%s for existing organism id=%s",
org_id,
organism.id_organisme,
)
if org_uuid and organism.uuid_organisme != org_uuid:
organism.uuid_organisme = org_uuid
updated = True
if org_name and organism.nom_organisme != org_name:
organism.nom_organisme = org_name
updated = True
if updated:
db.session.flush()
return organism
def authorize(self):
oauth_provider = getattr(oauth, self.id_provider)
token = oauth_provider.authorize_access_token()
session["openid_token_resp"] = token
user_info = token["userinfo"]
source_groups = (
user_info[self.group_claim_name] if self.group_claim_name in user_info else []
)
organism = self._resolve_organism(user_info, source_groups)
keycloak_user_uuid = None
uuid_claim_value = user_info.get(self.user_uuid_claim)
if uuid_claim_value:
try:
keycloak_user_uuid = str(uuid.UUID(str(uuid_claim_value)))
except (ValueError, TypeError):
current_app.logger.warning(
"Invalid user UUID claim '%s' value: %s",
self.user_uuid_claim,
uuid_claim_value,
)
new_user = {
"identifiant": user_info[self.identifier_field],
"email": user_info["email"],
"prenom_role": user_info["given_name"],
"nom_role": user_info["family_name"],
"active": True,
}
if keycloak_user_uuid:
new_user["uuid_role"] = keycloak_user_uuid
if organism:
new_user["id_organisme"] = organism.id_organisme
user = self.insert_or_update_role(
new_user, source_groups=source_groups, reconciliate_attr="identifiant"
)
db.session.commit()
return user
Les flux, côté GeoNature
Flux mobile (PKCE)
POST /api/auth/mobile/keycloak
Implémenté dans pypnusershub/routes.py. Requête :
{
"provider_id": "keycloak",
"code": "AUTHORIZATION_CODE_FROM_KEYCLOAK",
"code_verifier": "PKCE_CODE_VERIFIER",
"redirect_uri": "fr.geonature.occtax2://auth/callback",
"id_application": 3
}
Réponse en cas de succès :
{
"user": {
"id_role": 123,
"nom_role": "Dupont",
"prenom_role": "Jean",
"identifiant": "jdupont",
"id_organisme": 45,
"id_application": 3
},
"token": "JWT_GEONATURE",
"expires": "2026-04-08T14:22:11+00:00"
}
Code |
Type |
Cas |
|---|---|---|
400 |
|
Champ manquant, |
401 |
|
Code PKCE invalide ou expiré |
403 |
|
Utilisateur sans droit sur |
409 |
|
Conflit d’organisme |
500 |
|
Erreur technique |
Côté application, fichier backend/media/mobile/occtax/settings.json :
{
"auth_mode": "keycloak",
"keycloak": {
"provider_id": "keycloak",
"login_path": "api/auth/login/{provider_id}",
"redirect_uri": "fr.geonature.occtax2://auth/callback",
"mobile_login_path": "api/auth/mobile/keycloak"
}
}
Une liste blanche de redirect URIs peut être déclarée sur le provider dans le TOML, via
MOBILE_REDIRECT_URIS ou VALID_REDIRECT_URIS.
Des groupes Keycloak vers les groupes GeoNature
La clé de group_mapping est le chemin exact tel qu’il apparaît dans userinfo.groups ; la
valeur est l”id_role d’un groupe GeoNature, c’est-à-dire une ligne de t_roles avec
groupe = true.
Situation |
Résultat |
|---|---|
Nouvel utilisateur, mapping trouvé |
Groupes GeoNature assignés à la création |
Nouvel utilisateur, aucun mapping |
|
Utilisateur déjà existant |
Groupes non resynchronisés |
Avertissement
Retirer un utilisateur d’un groupe Keycloak ne lui retire pas son groupe GeoNature. Une
évolution « miroir strict » (ajout et retrait à chaque connexion) est possible dans
insert_or_update_role du sous-module UsersHub-authentification-module, mais ce n’est
pas le comportement par défaut. Pour tester un mapping, supprimer l’utilisateur GeoNature
puis se reconnecter : la logique ne s’applique qu’à la création.
Vérifier après le déploiement
Côté Keycloak
La feature
scriptsest active au démarrage :Preview features enabled: … scripts:v1dans les logs.Client Access Guardest visible dans Authentication → Flows → Add execution.Le Browser Flow du realm est bien
browser-rnf.Le mapper
groupsapparaît dans la réponseuserinfo.Le rôle
accessexiste surgeonature-local, et l’utilisateur de test appartient à/applications/geonature-local.
Côté GeoNature
GET /api/auth/providersretournekeycloaketlocal_provider.La connexion d’un utilisateur autorisé aboutit à une session valide.
La connexion d’un utilisateur non autorisé affiche le message générique de Keycloak.
uuid_roleetid_organismesont renseignés en base.
Les requêtes SQL de contrôle
-- Utilisateur
SELECT id_role, identifiant, uuid_role, id_organisme, active
FROM utilisateurs.t_roles
WHERE identifiant = '<login_test>';
-- Organisme résolu
SELECT id_organisme, uuid_organisme, nom_organisme
FROM utilisateurs.bib_organismes
WHERE id_organisme = <id_attendu>;
-- Droits sur l'application
SELECT *
FROM utilisateurs.v_userslist_forall_applications
WHERE identifiant = '<login_test>';
-- Groupes GeoNature affectés
SELECT r.identifiant, g.id_role AS groupe_id
FROM utilisateurs.t_roles r
LEFT JOIN utilisateurs.cor_roles cr ON cr.id_role_utilisateur = r.id_role
LEFT JOIN utilisateurs.t_roles g ON g.id_role = cr.id_role_groupe
WHERE r.identifiant = '<login_test>';
Si des id_organisme explicites sont injectés depuis Keycloak, resynchroniser la séquence,
sinon la prochaine insertion locale entrera en collision :
SELECT setval(
pg_get_serial_sequence('utilisateurs.bib_organismes', 'id_organisme'),
(SELECT COALESCE(MAX(id_organisme), 1) FROM utilisateurs.bib_organismes),
true
);
Dépannage
Côté Keycloak
Symptôme |
Diagnostic |
Action |
|---|---|---|
|
URI non identique |
Aligner exactement la redirect URI du client et le callback GeoNature |
|
Cookies perdus, deux hostnames |
Un seul hostname, vider les cookies, redémarrer |
|
Feature |
|
|
|
Tout mettre dans des fonctions |
|
JAR ajouté sans rebuild |
|
|
Droits insuffisants |
Lancer le build avec |
Message générique alors que l’utilisateur a les droits |
Rôle |
Vérifier le role mapping du groupe |
Côté claims et groupes
Symptôme |
Cause probable |
Correction |
|---|---|---|
|
Mapper absent ou Add to userinfo OFF |
Corriger le mapper |
|
Full group path OFF |
Activer Full group path |
|
Utilisateur membre d’aucun groupe |
Join Group sur l’utilisateur |
Mapper créé sur le client mais inactif |
Scope non assigné en Default |
Poser le mapper sur un scope Default du client |
|
Scope |
Client scopes → Default → |
|
Champs vides côté utilisateur |
Renseigner First name et Last name |
Côté GeoNature
Symptôme |
Cause probable |
Correction |
|---|---|---|
Organisme non créé |
Aucun groupe |
Rattacher l’utilisateur au groupe |
|
Service account sans droits |
Ajouter |
|
Claim |
Vérifier |
Groupes GeoNature non affectés |
Utilisateur déjà existant |
Recréer le compte, ou affecter les groupes à la main |
Mobile : 403 |
Pas de droit sur |
Vérifier |
Sécurité
Secrets — rotation de tous les
CLIENT_SECRETutilisés pendant la mise au point, avant l’ouverture aux utilisateurs.TLS de bout en bout — Keycloak, l’API et le frontend.
Journaux — ne jamais écrire un token, un
code, uncode_verifierou un secret en clair.Moindre privilège —
geonature-syncne détient quequery-groups, en lecture seule.Redirect URIs — liste blanche stricte, web comme mobile ; pas de joker sur un domaine entier.
Messages d’erreur — un seul libellé générique à la connexion.
Mobile — client public et PKCE obligatoire, jamais de secret embarqué dans l’application.
Sauvegarde — base de données sauvegardée avant la mise en production.
Accès de secours — conserver
local_provideret un compte administrateur local.
Checklist de déploiement sur un nouvel environnement
Côté Keycloak :
Installer le serveur, la base et le reverse proxy TLS.
Activer
features=preview,scripts, puiskc.sh build.Créer le realm et ses réglages (login, tokens, localisation, brute force).
Créer les clients
geonature-local,geonature-syncet mobile.Créer le rôle
accesset les groupes/applications/…, puis le role mapping.Configurer le mapper
groups(full path ON, userinfo ON).Créer les groupes
/organismes/…et leurs attributs.Déployer le JAR
client-access-guard.jaret basculer le browser flow.Déployer le thème de login.
Côté GeoNature :
Sauvegarder la base de données.
Déposer
keycloak_provider.pyet renseigner le bloc[AUTHENTICATION].Déclarer le
group_mappinget créer les groupes GeoNature cibles.Redémarrer le backend et vérifier
GET /api/auth/providers.Tester un utilisateur autorisé, puis un utilisateur non autorisé, puis le mobile.
Contrôler
uuid_roleetid_organismeen base.
Bonus : fédérer un annuaire LDAP
Keycloak devient la façade de l’annuaire : les comptes et les mots de passe restent dans OpenLDAP ou Active Directory, Keycloak en garde une copie locale et gère les groupes et rôles, GeoNature ne change pas (mêmes claims, même provider, même TOML). Le mot de passe reste vérifié par l’annuaire : Keycloak ne le copie pas et ne le stocke pas.
User federation → Add LDAP provider :
Champ |
OpenLDAP |
Active Directory |
|---|---|---|
Vendor |
Other |
Active Directory |
Connection URL |
|
|
Bind DN |
|
|
Users DN |
|
|
Username LDAP attribute |
|
|
RDN LDAP attribute |
|
|
UUID LDAP attribute |
|
|
User object classes |
|
|
Edit mode |
READ_ONLY |
READ_ONLY |
Import users |
ON |
ON |
Avant d’enregistrer, utiliser Test connection et Test authentication. Puis lancer Synchronize all users, et prévoir une synchronisation périodique si l’annuaire bouge. Un User LDAP filter permet de ne remonter qu’une sous-population.
Keycloak crée seul les mappers d’attributs (username, email, first name, last name) : ce sont
exactement les champs dont GeoNature a besoin. Pour remonter aussi les groupes de l’annuaire,
ajouter un group-ldap-mapper (LDAP Groups DN ou=groups,dc=example,dc=org,
Group Name LDAP Attribute cn, Group Object Classes groupOfNames,
Membership LDAP Attribute member, mode READ_ONLY).
Avertissement
Les groupes importés arrivent à la racine de l’arborescence, pas sous
/organismes/. Garder les groupes métier côté Keycloak plutôt que d’essayer de les faire porter par l’annuaire.En
READ_ONLY, prénom, nom et mot de passe ne se modifient plus que dans l’annuaire. C’est voulu, mais il faut le dire aux utilisateurs.Le
Username LDAP attributedevientpreferred_username, donct_roles.identifiant. En changer après coup casse la réconciliation des comptes existants.Supprimer puis recréer le provider de fédération recrée les utilisateurs côté Keycloak : nouveau
sub, donc nouveauuuid_role. À éviter en production.
Ce qui reste devant nous
Flux mobile PKCE — endpoint
POST /api/auth/mobile/keycloak, client public, redirection sur schéma natif, même logique de réconciliation, réponse JSON avec le JWT GeoNature.Synchronisation miroir — appliquer
group_mappingà chaque connexion, retraits compris, dansinsert_or_update_role, ce qui ferait de Keycloak la source unique des groupes. Décision d’architecture à prendre.Fédération et MFA — brancher un annuaire en amont, activer la double authentification pour les profils sensibles, sans toucher une ligne de GeoNature.
Un partenariat est engagé avec l’ONF pour développer des versions officielles d’Occtax mobile et de Monitoring mobile capables de s’authentifier auprès d’un fournisseur d’identité externe : Keycloak de notre côté, Azure / Entra ID du leur, même protocole. Côté GeoNature, rien de spécifique — un provider déclaré dans le TOML, comme pour le web.
Les trois choses à retenir
Keycloak authentifie, GeoNature autorise.
Le mapper
groupsdoit alimenteruserinfo.Les attributs d’organisme passent par l’API admin, pas par le token.
Pour aller plus loin
Documentation du dépôt GeoNature :
docs/KEYCLOAK_GEONATURE.md(guide exhaustif),DEPLOYMENT_KEYCLOAK.md(note de déploiement),backend/geonature/keycloak_provider.py(code du provider).
Comment le provider est chargé
La clé
moduledu TOML est un chemin d’import Python : le provider peut vivre dans GeoNature, dans un module tiers ou danspypnusershub. Aucune inscription au registre, aucun point d’entrée setuptools — il suffit que la classe soit importable.id_providerdevient le segment d’URL des routes/auth/login/<id>et/auth/authorize/<id>, et plusieurs providers OIDC peuvent coexister avec chacun son bloc de configuration.Écrire un provider custom revient donc à sous-classer, surcharger
configure()etauthorize(), et changer une ligne de TOML.