Nodefony 10.0.0-beta.5
Framework Node.js fullstack — HTTP & WebSocket, même contexte

Keycloak — brancher un annuaire, du realm au premier login

stable@nodefony/securitymis à jour 2026-10-12

Ton entreprise tient ses comptes dans un Keycloak et tu veux que tes utilisateurs se connectent à ton application avec ces comptes, sans que l’application voie jamais un mot de passe. Côté Nodefony, il n’y a aucun code à écrire : le fournisseur keycloak est fourni, et l’adresse du realm suffit à le décrire. Le travail se passe surtout dans Keycloak : créer le realm, déclarer un client confidentiel, enregistrer l’URL de retour au caractère près. Cette page fait le chemin entier, puis montre comment le jeton Keycloak de la même personne ouvre aussi ton API, sur le même compte. Ancré sur le fournisseur createDiscoveredOidcProvider() (oidc.ts#createDiscoveredOidcProvider) et sur OAuth2Service (oauth2.ts#OAuth2Service).

📍 Documentation › Sécurité › Keycloak

🧠 Schéma général#

Le navigateur ne voit passer qu’un code à usage unique. Le secret du client et les jetons restent entre l’application et Keycloak, sur un canal TLS.

📖 Lexique#

Terme Développé et rôle
Keycloak Serveur d’identité libre, auto-hébergé : il tient les comptes, affiche la page de connexion et délivre les jetons.
Realm Espace de comptes isolé dans Keycloak : ses utilisateurs, ses clients, ses clés. Son URL est l’émetteur.
Client L’application telle que Keycloak la connaît : un identifiant, un secret, des URL de retour autorisées.
Client confidentiel Client capable de garder un secret, parce qu’il tourne sur un serveur. Keycloak l’appelle « Client authentication ON ».
OIDC OpenID Connect : la couche d’identité au-dessus d’OAuth 2.0, qui ajoute l’ID token (qui est l’utilisateur).
Émetteur (iss) L’identifiant du serveur qui signe les jetons. Pour Keycloak : https://<hôte>/realms/<realm>.
sub Subject : l’identifiant stable de l’utilisateur dans son realm. Unique chez son émetteur, nulle part ailleurs.
PKCE Proof Key for Code Exchange (RFC 7636) : rend inutilisable un code volé en route.
BFF Backend For Frontend : le serveur garde les jetons et ne donne au navigateur qu’un cookie de session.
Shadow User Le compte local que l’application crée au premier login, lié à (keycloak, sub).
Audience (aud) La ressource à laquelle un jeton d’accès est destiné ; ton API refuse un jeton qui ne la nomme pas.

Qu’est-ce que c’est ?#

Keycloak est le guichet d’accueil d’un immeuble. Chaque société de l’immeuble (chaque application) fait confiance au badge qu’il délivre, sans tenir son propre registre de visiteurs. Le visiteur montre ses papiers une fois, au guichet ; les sociétés ne voient que le badge.

Pour ton application, cela bloque trois risques concrets :

La vision Nodefony#

Aucun code propre à Keycloak. Un serveur OpenID Connect publie lui-même ses points d’entrée ; le fournisseur keycloak les découvre à partir de l’émetteur, et c’est tout ce qu’il sait faire (createDiscoveredOidcProvider(), oidc.ts#createDiscoveredOidcProvider). Changer de realm, c’est changer une URL.

Une configuration fausse arrête le démarrage. Le fournisseur keycloak est enregistré avec requiresIssuer: true (oauthProviderRegistry.ts#withKeycloakThemeHint). Au boot, un émetteur absent, ou qui n’est pas une URL https sans requête ni fragment, lève une erreur qui nomme la clé (checkProviderIssuer(), oauth2.ts#checkProviderIssuer). Un Keycloak éteint, lui, ne bloque rien : le bouton disparaît de l’écran de connexion et revient tout seul quand le realm répond à nouveau (#isReachable(), oauth2.ts#OAuth2Service.#isReachable).

L’identité est la paire (keycloak, sub), jamais l’email. Au premier login, l’application crée un compte local lié à cette paire (UserService.provisionOAuthUser(), UserService.ts#UserService.provisionOAuthUser). Un compte local qui porte déjà le même identifiant sans ce lien n’est jamais rattaché en silence : la connexion est refusée (UserService.ts#UserService.changePassword).

Keycloak authentifie, l’application autorise. Les rôles du realm ne sont pas lus : le compte reçoit defaultRoles à sa création, puis la base locale fait foi. Une élévation se fait dans l’application, pas dans Keycloak.

🚀 Démarrage rapide#

Essayer en deux minutes : le Keycloak du dépôt#

Le dépôt fournit un Keycloak 26.8 déjà configuré : un realm nodefony, un client confidentiel nodefony-dev, ses deux rôles client admin et admin-nodefony, et trois utilisateurs. Tout est écrit dans docker/keycloak/import/realm-nodefony.json, importé au premier démarrage du conteneur.

Utilisateur Mot de passe Rôles Keycloak (client nodefony-dev) Rôles obtenus dans l’application
alice alice-dev aucun ROLE_USER
bob bob-dev admin ROLE_USER, ROLE_ADMIN
cci cci-dev admin, admin-nodefony + ROLE_NODEFONY_ADMIN (plateforme)

cci doit configurer un code à usage unique (TOTP) à sa première connexion ("requiredActions": ["CONFIGURE_TOTP"]) : le compte porte le rôle de plateforme, il exige un second facteur, et aucune graine n’est écrite dans le fichier.

  1. Démarrer le conteneur (en https sur le port 8444, avec le certificat de l’application) :

    docker compose -f docker/docker-compose.yml --profile keycloak up -d keycloak
    
  2. Brancher l’application : poser dans .env (ignoré par git) les trois lignes ci-dessous — valeurs publiques, écrites dans le realm importé.

    NF_KEYCLOAK_ISSUER=https://localhost:8444/realms/nodefony
    NF_KEYCLOAK_CLIENT_ID=nodefony-dev
    NF_KEYCLOAK_CLIENT_SECRET=nodefony-dev-keycloak-secret
    

    Ces valeurs sont publiques : elles n’existent que dans ce décor de développement.

  3. Démarrer l’application, ouvrir https://localhost:5152/nodefony/login : le bouton Keycloak est là. Se connecter avec alice / alice-dev.

La console d’administration du realm est sur https://localhost:8444/admin (admin / nodefony-dev). Les retouches qu’on y fait survivent aux redémarrages ; docker compose … down -v repart du fichier.

Essayer dans une application générée#

Une application créée par nodefony create app avec le préréglage complete porte déjà le même décor, à son nom : un profil keycloak dans son compose.yaml, les variables NF_KEYCLOAK_* déclarées dans env.ts et le fournisseur conditionnel dans nodefony/config/security.ts. Tant qu’on ne l’allume pas, l’application démarre sans bouton ni avertissement.

Le realm docker/keycloak/import/realm.json (realm et client nommés comme l’application, utilisateur alice) et le thème docker/keycloak/themes/nodefony/ ne viennent PAS du générateur : ils sont livrés par ce paquet (nodefony/keycloak/scaffold.ts, appelé par le point de contribution nodefony/scaffold/contribute.ts déclaré dans package.json sous nodefony.contribute), et posés après l’installation. Ce sont de vrais fichiers, versionnés avec l’application : le thème se retouche à ses couleurs. Les fichiers du thème sont des copies suivies (empreintes dans scaffold.lock.json) ; le realm est une semence, qui appartient à l’application dès qu’il existe (security:keycloak:realm --write le fait évoluer).

npx nodefony scaffold:sync            # pose ce qui manque (création en --no-install)
npx nodefony scaffold:sync --update   # après un npm update : rafraîchit le thème resté intact

Un fichier du thème retouché par l’application n’est jamais remplacé : il est signalé, et pour reprendre la version du paquet on le supprime avant de relancer.

Rien ne prévient au démarrage qu’un npm update a laissé le thème en arrière : la page de connexion de Keycloak garde simplement l’ancienne apparence. C’est nodefony doctor --live qui le dit, famille « Fichiers livrés par les paquets » — une copie intacte en retard, ou un fichier livré absent (un habillage ajouté par la nouvelle version), avec le geste nodefony scaffold:sync --update. Une copie retouchée n’y est jamais signalée. Le diagnostic lit le même plan que la console d’administration (GET /nodefony/kernel/api/scaffold), donc en développement seulement ; ailleurs la famille est rendue « non contrôlée », avec sa raison.

Le thème couvre les cinq types de Keycloak, chacun hérité du thème de Keycloak qu’il habille (seuls le logo, les couleurs et quelques textes sont posés — les pages restent celles de Keycloak, donc justes à chaque mise à jour) :

Type Ce qu’il habille Hérite de Choisi par
login connexion, mot de passe oublié, erreurs keycloak.v2 le realm (loginTheme)
account console du compte de l’utilisateur keycloak.v3 le realm (accountTheme)
admin console d’administration keycloak.v2 le realm (adminTheme)
email enveloppe HTML de tous les courriels keycloak le realm (emailTheme)
welcome page d’accueil du serveur (avant le premier admin) keycloak le SERVEUR : KC_SPI_THEME__WELCOME_THEME: nodefony

L’habillage de la page /login suit jusqu’à Keycloak. Chaque habillage de loginPage.skin a son thème de connexion Keycloak : nodefony pour frontispiece (le défaut), nodefony-<habillage> pour les huit autres (nodefony-blueprint, nodefony-horizon…). Ce sont des thèmes ENFANTS de nodefony : même gabarit, mêmes textes, plus la feuille de l’habillage (copie conforme de celle du framework) et sa mise en page. Toutes les pages du parcours en héritent — connexion, code à usage unique, configuration du second facteur, mot de passe oublié. Le compose monte le dossier docker/keycloak/themes/ ENTIER, pour que Keycloak les voie tous ; la console les propose aussi dans Realm settings › Themes. Limite : seule l’enveloppe de la page est à Nodefony, les formulaires restent ceux de Keycloak — ledger (colonnes et étapes numérotées) et horizon (bandeau photo dans la carte) n’y portent que leurs couleurs et leur mise en page.

Le compose pose aussi KC_SPI_THEME__DEFAULT: nodefony : le realm master, que l’import ne touche pas, prend le même thème — sa connexion et sa console d’administration comprises. Clair et sombre suivent le réglage du système (prefers-color-scheme) ; le réglage Dark mode du realm le coupe. Le realm master n’est pas importé (Keycloak le crée au premier démarrage, l’import ne le touche pas) : il naît sans internationalisation, donc en anglais et sans sélecteur de langue. Le régler une fois dans sa console — Realm settings › Localization : internationalisation activée, fr et en, fr par défaut. Le réglage vit dans le volume keycloak-data : il survit aux redémarrages, pas à un down -v. Sur sa page de connexion, le bouton « Retour à … » n’apparaît pas : il annule la connexion, et les consoles de Keycloak lui-même ne savent pas traiter cette annulation.

Les comptes du realm généré reçoivent le rôle default-roles-<realm> : un compte importé n’a QUE les rôles qu’on lui liste, et sans celui-là la console du compte lui refuse son propre profil (401).

Le realm généré ne déclare aucun serveur d’envoi de courriels : « mot de passe oublié » reste coupé tant que l’application n’en branche pas un (console : Realm settings › Email). Le dépôt, lui, en a un de test — Mailpit (--profile keycloak, interface http://localhost:8025).

  1. Fabriquer le certificat de développement (une fois — Keycloak le sert en https) :

    npx nodefony http:certificates
    
  2. Démarrer le conteneur :

    docker compose --profile keycloak up -d keycloak
    

    Sous Linux natif, poser d’abord export KEYCLOAK_UID=$(id -u) : sinon la clé privée montée est illisible par le conteneur.

  3. Brancher l’application : rien à faire. Le .env généré porte déjà les lignes NF_KEYCLOAK_*, actives et alignées sur le realm généré. Keycloak éteint, l’application démarre quand même : un avertissement, pas de bouton, et le bouton revient tout seul dès que Keycloak répond. Pour t’en passer, commente ces lignes.

  4. Démarrer l’application, puis ouvrir https://localhost:5152/nodefony/login et se connecter avec alice / alice-dev :

    npm run dev
    

    Rien d’autre à poser, sous Windows comme ailleurs. Le Keycloak du décor sert le certificat de développement de l’application, et l’application reconnaît d’office l’autorité qui l’a signé : en développement, le service de certificats l’ajoute aux autorités de confiance du processus dès le démarrage, commandes en ligne comprises (addDefaultCaCertificate(), certificates.ts#addDefaultCaCertificate). La variable NODE_EXTRA_CA_CERTS n’est plus nécessaire.

Le realm généré porte aussi, comme celui du dépôt, un client machine <app>-machine (compte de service, client_credentials). Ses jetons portent l’audience de la zone partner que le gabarit complete ouvre (https://localhost:5152/api/partner) : npx nodefony app:partner:call --issuer keycloak obtient un jeton et appelle GET /api/partner/whoami dès le premier démarrage. Quand tu ouvres une autre zone (section « Le jeton Keycloak sur ton API », plus bas), npx nodefony security:keycloak:realm --write ajoute le mapper qui inscrit sa resource dans les jetons.

Le port de Keycloak (8444) se change par KEYCLOAK_PORT au up — l’émetteur suit, donc NF_KEYCLOAK_ISSUER aussi. En production, le décor ne sert plus : les trois variables pointent ton Keycloak, et NF_OAUTH_REDIRECT_BASE porte l’URL publique de l’application, base de l’URL de retour.

Le realm écrit depuis ta configuration#

Les sections 1 et 2 ci-dessous déroulent les réglages à la main. Une fois le fournisseur branché (section 4), Nodefony sait les écrire lui-même : nodefony security:keycloak:realm dérive le realm de la configuration EFFECTIVE de l’application — client, URL de retour, déconnexion par canal arrière, audience de chaque zone, rôles de roleMapping.

npx nodefony security:keycloak:realm             # affiche le realm
npx nodefony security:keycloak:realm --write     # l'écrit dans docker/keycloak/import/
npx nodefony security:keycloak:realm --check     # sort en 1 s'il ne suit plus la config (CI)
npx nodefony security:keycloak:realm --apply     # pose le thème de connexion sur le realm VIVANT (dev)

Le thème suit l’habillage de /login — --apply, et le service keycloak#

Changer loginPage.skin ne change pas la page de Keycloak : le thème vit dans SA base (réglage loginTheme du realm). --apply le pose par l’API d’administration de Keycloak, qui exige le compte administrateur du SERVEUR (realm master) — celui du compose de développement :

// nodefony/config/security.ts — le compte ne passe qu'en développement
keycloak: {
  issuer: ctx.env.NF_KEYCLOAK_ISSUER,
  // …
  ...(ctx.isDev
    ? { admin: { username: ctx.env.NF_KEYCLOAK_ADMIN_USERNAME, password: ctx.env.NF_KEYCLOAK_ADMIN_PASSWORD } }
    : {}),
},

Le client machine suivi est celui qu’on nomme (--machine <clientId>), sinon le compte de service que le fichier déclare déjà. Le canal arrière vise http://host.docker.internal:<port> en développement (Keycloak vit dans un conteneur) ; --backchannel-origin le déplace.

1. Créer le realm#

Dans la console d’administration de ton Keycloak :

  1. Create realm (à côté de Current realm), donner un nom — par exemple mon-entreprise.
  2. Le realm créé devient le realm courant. Ses utilisateurs s’y créent sous Users, ou s’y importent depuis un annuaire LDAP (User federation).

Ne pas travailler dans le realm master : il administre Keycloak lui-même.

2. Déclarer le client confidentiel#

Clients › Create client, type OpenID Connect. Les réglages qui comptent :

Réglage Keycloak Valeur Pourquoi
Client ID mon-app Devient NF_KEYCLOAK_CLIENT_ID.
Client authentication ON Client confidentiel : l’application tourne sur un serveur, elle garde un secret. Nodefony en exige un.
Standard flow coché C’est le flux Authorization Code, le seul que Nodefony emploie.
Direct access grants décoché Ce flux fait transiter le mot de passe par l’application — exactement ce qu’on évite.
Implicit flow décoché Retiré par OAuth 2.1 : il livre le jeton dans l’URL.
PKCE method S256 Keycloak exige alors PKCE pour ce client ; Nodefony l’envoie toujours.
Valid redirect URIs https://app.example.com/nodefony/security/api/oauth2/keycloak/callback Comparée au caractère près (RFC 9700). Pas de joker en production.
Valid post logout redirect URIs https://app.example.com/* Retour du navigateur après la déconnexion initiée par l’application.
Front channel logout OFF Il exige une page que l’application n’a pas ; activé, Keycloak n’appelle PAS le canal arrière.
Backchannel logout URL https://app.example.com/nodefony/security/api/oauth2/keycloak/backchannel-logout Keycloak y prévient l’application quand la session SSO se ferme ailleurs. Joignable par Keycloak.
Backchannel logout session required ON Le jeton porte le sid : seule LA session concernée est fermée, pas toutes celles du compte.

Puis l’onglet Credentials : copier le Client secret. C’est NF_KEYCLOAK_CLIENT_SECRET.

⚠️ Attention

L’URL de retour enregistrée chez Keycloak et celle que l’application envoie doivent être identiques : schéma, hôte, port, chemin. localhost et 127.0.0.1 sont deux hôtes différents. Une différence d’un caractère donne Invalid parameter: redirect_uri sur la page Keycloak, avant même le formulaire de connexion.

3. Lire l’émetteur#

Realm settings › onglet General › lien OpenID Endpoint Configuration. Le document qui s’ouvre commence par la valeur à recopier :

{ "issuer": "https://sso.example.com/realms/mon-entreprise", "…": "…" }

C’est NF_KEYCLOAK_ISSUER, sans barre finale ni /.well-known/…. Elle doit être en https.

Keycloak calcule cette valeur à partir de son nom d’hôte. Derrière un proxy, ou quand le navigateur et l’application ne joignent pas Keycloak par la même adresse, fixer KC_HOSTNAME à l’URL publique complète : sinon l’émetteur change selon qui demande, et l’application refuse les jetons.

4. Brancher l’application#

Les trois valeurs entrent par env.ts, seul lecteur des variables d’environnement. Le fournisseur n’est déclaré que si les trois sont présentes : pas de bouton mort quand une variable manque.

// nodefony/config/security.ts — le fragment du module, importé par le manifeste
import type { ConfigContext } from "nodefony";
import type { ISecurityConfigInput } from "@nodefony/security";
import type { env } from "../../env";

export const securityConfig = (ctx: ConfigContext<typeof env>) =>
  ({
    oauth2: {
      // Rôles posés à la CRÉATION du compte local ; ceux du realm ne sont pas lus.
      defaultRoles: ["ROLE_USER"],
      providers: {
        ...(ctx.env.NF_KEYCLOAK_ISSUER &&
        ctx.env.NF_KEYCLOAK_CLIENT_ID &&
        ctx.env.NF_KEYCLOAK_CLIENT_SECRET
          ? {
              keycloak: {
                issuer: ctx.env.NF_KEYCLOAK_ISSUER,
                clientId: ctx.env.NF_KEYCLOAK_CLIENT_ID,
                clientSecret: ctx.env.NF_KEYCLOAK_CLIENT_SECRET,
                redirectUri: `${ctx.env.NF_OAUTH_REDIRECT_BASE}/nodefony/security/api/oauth2/keycloak/callback`,
                // Libellé du bouton ; omis, il vaut « Keycloak ».
                label: "Compte entreprise",
              },
            }
          : {}),
      },
    },
  }) satisfies ISecurityConfigInput;
// env.ts — SEUL lecteur de process.env (catalogue typé, validé au boot).
// nodefony.config.ts — `ctx.env` EST ce catalogue (typé par le paramètre générique).
import { defineConfig, defineEnv, envString, use } from "nodefony";
import { securityConfig } from "./nodefony/config/security";

export const env = defineEnv({
  // Émetteur = URL du realm, en https (la découverte et le contrôle de `iss` en partent).
  NF_KEYCLOAK_ISSUER: envString({ optional: true }),
  NF_KEYCLOAK_CLIENT_ID: envString({ optional: true }),
  NF_KEYCLOAK_CLIENT_SECRET: envString({ optional: true }),
  // Base des URL de retour : celle que Keycloak a enregistrée, au caractère près.
  NF_OAUTH_REDIRECT_BASE: envString({ default: "https://localhost:5152" }),
});

export default defineConfig<typeof env>((ctx) => ({
  modules: [
    "@nodefony/http",
    "@nodefony/framework",
    use("@nodefony/security", securityConfig(ctx)),
  ],
}));

L’application doit faire confiance au certificat de Keycloak, puisqu’elle l’appelle en https. C’est comme un badge présenté à l’accueil : l’application ne laisse entrer que les badges signés par une autorité qu’elle connaît. Trois cas :

Le certificat de Keycloak est signé par… Ce qu’il faut faire
une autorité publique (Let’s Encrypt…) rien
l’autorité de développement de Nodefony (le Keycloak du décor) rien : reconnue d’office en développement
la même, mais l’application tourne en production sur le poste la déclarer : NF_TLS_CA_FILE=nodefony/config/certificates/ca/nodefony-root-ca.crt.pem
l’autorité de ton entreprise (Keycloak interne) la déclarer : NF_TLS_CA_FILE=certs/autorite-entreprise.pem dans .env, ou dans le Secret du pod

NF_TLS_CA_FILE alimente la clé certificates.extraCaFiles du module @nodefony/http : un ou plusieurs fichiers PEM, absolus ou relatifs à la racine de l’application, une chaîne entière (racine + intermédiaires) pouvant tenir dans un seul fichier. Ils sont ajoutés aux autorités du processus au démarrage, avant tout service (trustCaFile(), certificates.ts#trustCaFile), et servent à tous ses appels sortants : découverte, point de jeton, jeu de clés. Un fichier introuvable, ou sans certificat, interrompt le démarrage en nommant le chemin (code 78) — plutôt qu’un fetch failed au premier clic d’un utilisateur. Seule exception : l’autorité de développement pas encore engendrée est ignorée en mode development (le journal le dit) ; en production, elle doit exister — npx nodefony http:certificates l’engendre. C’est l’équivalent, porté par la configuration, de NODE_EXTRA_CA_CERTS : même effet, sans syntaxe de shell à traduire sous Windows.

Pourquoi la deuxième ligne du tableau : l’autorité de développement n’est reconnue d’office qu’en mode development (Certificate.trustDevelopmentAuthority(), certificates.ts#Certificate.trustDevelopmentAuthority). Lancée en production — pour essayer le build sur le poste, par exemple —, l’application ne lui fait plus confiance sans qu’on le lui dise, et c’est voulu : une application de production qui adopterait en silence une autorité de développement ouvrirait une porte qu’aucune configuration ne montre. Le refus nomme la variable à poser (certificat refusé (SELF_SIGNED_CERT_IN_CHAIN) → NF_TLS_CA_FILE=…), et le .env généré porte déjà la ligne exacte, en commentaire.

5. Le premier login — ce qu’on observe#

# Le bouton est offert à l'écran de connexion :
curl -s https://localhost:5152/nodefony/security/api/oauth2/providers
# {"providers":[{"name":"keycloak","label":"Compte entreprise"}]}

Au clic, l’utilisateur passe par la page de connexion du realm, puis revient connecté. Côté application :

Les écrans Utilisateurs et Sessions de Studio montrent le compte et sa session.

⚙️ Configuration#

Les clés de security.oauth2.providers.keycloak — le schéma complet vit sur la page OAuth2.

Clé Requise Effet
issuer ✅ URL https du realm. Absente ou mal formée : le démarrage est refusé.
clientId ✅ Client ID du client Keycloak.
clientSecret ✅ Client secret de l’onglet Credentials. Jamais journalisé.
redirectUri ✅ URL de retour, identique à l’une des Valid redirect URIs du client.
discoveryUrl Adresse COMPLÈTE où lire le document de découverte, quand l’application ne joint pas l’émetteur (service interne d’un cluster). Voir En cluster.
clientAuthMethod client_secret_basic par défaut ; client_secret_post si le client Keycloak l’exige.
scopes Vide = openid, profile, email.
defaultRoles Rôles du compte à sa création, pour CE fournisseur (sinon la valeur globale oauth2.defaultRoles).
roleMapping Rôles Keycloak → rôles de l’application, recalculés à chaque connexion et à chaque jeton. Voir Rôles.
rolesSource Où lire les rôles : client (défaut), realm, groups. Sans roleMapping, sans effet.
allowPlatformRoles true autorise roleMapping à donner un ROLE_NODEFONY_*. Omis : démarrage refusé.
audiences Ressources pour lesquelles ce realm émet des jetons à l’application. Lue par security:keycloak:realm seulement. Omis : toutes les zones external-jwt.
label Libellé du bouton. Omis : « Keycloak ».
hidden Retire le bouton sans fermer le flux (lien direct, sous-domaine dédié).

🎭 Rôles — gérer les droits dans Keycloak#

L’idée. Sans réglage, Keycloak dit seulement à l’application qui se connecte. Les droits (ROLE_ADMIN…) sont posés à la création du compte, puis gérés dans l’application, à la main. Une entreprise qui a Keycloak veut souvent l’inverse : promouvoir quelqu’un une fois, dans Keycloak, et que l’application suive. C’est le rôle de la table roleMapping — un dictionnaire de traduction : « quand Keycloak dit admin, chez moi ça s’appelle ROLE_ADMIN ».

Côté Keycloak, deux gestes dans la console du realm :

  1. Clients › ton client › Roles › Create role : admin. Le rôle appartient au client, c’est-à-dire à TON application — pas à tout le realm.
  2. Users › l’utilisateur › Role mapping › Assign role › filtre client roles › admin.

Dans un realm importé, c’est le bloc roles.client et la clé clientRoles de l’utilisateur :

"roles": { "client": { "mon-app": [{ "name": "admin" }] } },
"users": [{ "username": "bob", "clientRoles": { "mon-app": ["admin"] } }]

Côté Nodefony, la table sur le fournisseur :

keycloak: {
  issuer: ctx.env.NF_KEYCLOAK_ISSUER,
  clientId: ctx.env.NF_KEYCLOAK_CLIENT_ID,
  clientSecret: ctx.env.NF_KEYCLOAK_CLIENT_SECRET,
  redirectUri: "https://app.example.com/nodefony/security/api/oauth2/keycloak/callback",
  roleMapping: { admin: "ROLE_ADMIN" },
},

Ce qui se passe à la connexion de bob :

  1. Keycloak rend un jeton d’accès qui porte "resource_access": { "mon-app": { "roles": ["admin"] } } — c’est là, et seulement là par défaut, que Keycloak met les rôles (ni dans l’ID token, ni dans userinfo).
  2. mapProviderRoles() (providerRoles.ts#mapProviderRoles) traduit admin → ROLE_ADMIN par la table.
  3. Le compte de bob reçoit ROLE_ADMIN (reconcileProviderRoles(), user/nodefony/src/providerRoles.ts#reconcileProviderRoles côté @nodefony/user).

Les règles, à connaître avant de l’activer :

🧩 Le jeton Keycloak sur ton API — le même compte#

Une fois les humains connectés par le navigateur, un script ou un service veut souvent appeler l’API avec un jeton d’accès du même realm. La page Jetons d’un émetteur tiers décrit cette brique en entier ; voici ce que Keycloak y ajoute.

Côté Keycloak, le jeton doit nommer ton API dans son audience (aud) — par défaut il porte account, que toute zone refuse. nodefony security:keycloak:realm --write écrit le mapper pour chaque ressource listée dans audiences du fournisseur, sinon pour la resource de chaque zone external-jwt. À la main : dans le client, Client scopes › le scope dédié › Add mapper › Audience, avec Included Custom Audience = la resource de la zone et Add to access token coché.

💡 Astuce

Écris audiences dès qu’une zone external-jwt ne doit PAS recevoir les jetons de ce realm (une API d’un autre domaine de confiance, une zone de test) : sans la liste, la commande déduit l’audience de toutes les zones, et Keycloak imprimerait aussi l’adresse de celle-là.

Côté Nodefony, déclarer le realm comme émetteur de confiance et ouvrir une zone qui accepte ses jetons :

// nodefony/config/security.ts
import type { ISecurityConfigInput } from "@nodefony/security";

export const securityConfig = () =>
  ({
    resourceServer: {
      issuers: [
        // Les clés publiques du realm sont découvertes par l'émetteur.
        {
          issuer: "https://sso.example.com/realms/mon-entreprise",
          algorithms: ["RS256"],
        },
      ],
    },
    areas: {
      api: {
        pattern: "^/api",
        authenticators: ["external-jwt"],
        stateless: true,
        // L'audience exigée : celle que le mapper inscrit dans le jeton.
        resource: "https://api.example.com",
      },
    },
  }) satisfies ISecurityConfigInput;

Le même compte, pas un second. Quand l’émetteur d’un jeton est celui d’un fournisseur de connexion déclaré (oauth2.providers.keycloak.issuer), l’authenticator cherche d’abord le compte lié à (keycloak, sub) — celui que le login par navigateur a créé (ExternalSubjectResolver.ts#IExternalIssuerBinding.issuer). Sans ce lien, le jeton désignerait un compte distinct, sous l’identifiant <émetteur>#<sub>.

☸️ En cluster — joindre Keycloak par son adresse interne#

Dans un cluster Kubernetes, ton application et Keycloak tournent côte à côte. Pour se parler, ils passent par le service interne (http://keycloak.sso:8080), sans ressortir sur Internet. Les navigateurs, eux, ne connaissent que l’adresse publique (https://sso.example.com).

C’est comme le standard téléphonique d’une entreprise : entre collègues on compose le poste interne, de l’extérieur le numéro public — mais la personne qui décroche se présente toujours sous le même nom. Ce nom, c’est l’émetteur (issuer) : l’adresse publique du realm, celle qu’on retrouve dans chaque jeton.

Sans réglage, l’application lit la « carte d’identité » de Keycloak (le document de découverte) à l’adresse de l’émetteur, donc l’adresse publique — que bien des pods ne joignent pas, ou seulement en ressortant par l’ingress. Et si on écrit l’adresse interne à la place de l’émetteur, Keycloak répond « je m’appelle https://sso.example.com/… » : l’application refuse, et elle a raison — c’est la garde qui empêche un serveur de parler au nom d’un autre (RFC 8414 §3.3).

discoveryUrl dit les deux à la fois : lis la carte ici, mais exige que la personne se présente sous ce nom-là.

Côté Keycloak, deux réglages (guide Keycloak Configuring the hostname) :

# Le nom public, en URL COMPLÈTE : c'est lui que porteront `iss` et les pages de connexion.
KC_HOSTNAME=https://sso.example.com
# Les points appelés de serveur à serveur suivent l'adresse par laquelle on interroge Keycloak.
KC_HOSTNAME_BACKCHANNEL_DYNAMIC=true

Côté application, une variable de plus dans le Secret du pod, à côté des trois NF_KEYCLOAK_* :

NF_KEYCLOAK_DISCOVERY_URL=http://keycloak.sso:8080/realms/mon-entreprise/.well-known/openid-configuration

Le gabarit complete la porte déjà vers les deux usages du realm — la connexion (le bouton) et les jetons sur ton API :

use("@nodefony/security", {
  oauth2: {
    providers: {
      keycloak: {
        issuer: ctx.env.NF_KEYCLOAK_ISSUER, // https://sso.example.com/realms/mon-entreprise
        discoveryUrl: ctx.env.NF_KEYCLOAK_DISCOVERY_URL, // le service interne
        clientId: ctx.env.NF_KEYCLOAK_CLIENT_ID,
        clientSecret: ctx.env.NF_KEYCLOAK_CLIENT_SECRET,
        redirectUri: `${ctx.env.NF_OAUTH_REDIRECT_BASE}/nodefony/security/api/oauth2/keycloak/callback`,
      },
    },
  },
  resourceServer: {
    issuers: [
      {
        issuer: ctx.env.NF_KEYCLOAK_ISSUER,
        discoveryUrl: ctx.env.NF_KEYCLOAK_DISCOVERY_URL,
        algorithms: ["RS256"],
      },
    ],
  },
});

Ce qu’on observe — le document qu’un Keycloak 26.8 réglé ainsi rend quand on l’interroge par son adresse interne (constaté, pas déduit) :

Point d’entrée Qui l’appelle Adresse publiée
issuer — https://sso.example.com/…
authorization_endpoint le navigateur https://sso.example.com/…
end_session_endpoint le navigateur https://sso.example.com/…
token_endpoint l’application (de serveur à serveur) http://keycloak.sso:8080/…
jwks_uri l’application (de serveur à serveur) http://keycloak.sso:8080/…

Ce qui reste vérifié, et ne se relâche jamais :

⚠️ Attention

Une adresse interne en http fait passer le secret du client et les jetons en clair sur le réseau du cluster. C’est le choix courant derrière un maillage de services (mTLS) ou dans un réseau isolé ; sinon, servir Keycloak en https sur l’adresse interne aussi, et déclarer son autorité par NF_TLS_CA_FILE.

🔐 Sécurité#

⚠️ Pièges#

Avant de lire le tableau, demander le diagnostic : il éprouve le branchement sans qu’un humain se connecte, et nomme la cause là où le journal ne montre qu’un 401.

npx nodefony security:oauth:doctor               # tous les fournisseurs configurés
npx nodefony security:oauth:doctor -p keycloak   # un seul ; --json pour un script

Trois sondes, chacune avec son verdict : découverte (émetteur annoncé ≠ émetteur configuré, RFC 8414 §3.3), URL de retour (la requête du bouton, non suivie : Keycloak refuse en 400 une URL qu’il ne connaît pas), secret (un code inventé présenté avec le vrai secret : invalid_grant = secret accepté). Sortie en 1 si une sonde échoue. nodefony doctor --live rend les mêmes échecs (famille « Fournisseurs OAuth »), l’administration les sert sur /nodefony/security/api/oauth/diagnosis. Côté Keycloak, la sonde ouvre une session de connexion vide, qui expire seule.

Symptôme Cause Correction
Démarrage refusé : security.oauth2.providers.keycloak.issuer est requis Les trois variables ne sont pas posées ensemble, ou issuer manque à la main Poser NF_KEYCLOAK_ISSUER (checkProviderIssuer(), oauth2.ts#checkProviderIssuer)
Démarrage refusé : émetteur invalide … https Émetteur en http, ou avec ?…/#… Recopier l’issuer du document OpenID Endpoint Configuration
Pas de bouton + WARNING oauth2 provider "keycloak" indisponible Keycloak injoignable, ou certificat non reconnu (fetch failed (SELF_SIGNED_CERT_IN_CHAIN …)) Démarrer Keycloak ; déclarer l’autorité d’entreprise par NF_TLS_CA_FILE. Retour sous 30 s
Démarrage refusé (78) : http.certificates.extraCaFiles : « … » est illisible Le fichier de NF_TLS_CA_FILE n’existe pas à ce chemin (relatif = à la racine de l’application) Corriger le chemin, ou monter le fichier dans le pod
Avec discoveryUrl : le document déclare « http://keycloak.sso:8080/… » alors qu'il a été demandé à « https://sso… » KC_HOSTNAME absent : Keycloak prend le nom de l’adresse par laquelle on l’interroge KC_HOSTNAME = l’URL publique COMPLÈTE
Avec discoveryUrl : le bouton est là, mais le retour de connexion échoue en fetch failed vers l’adresse publique KC_HOSTNAME_BACKCHANNEL_DYNAMIC absent : Keycloak publie son point de jeton sur l’adresse publique KC_HOSTNAME_BACKCHANNEL_DYNAMIC=true
Avec discoveryUrl : « token_endpoint » doit être en https (ou en http sur l'origine de discoveryUrl …) Keycloak publie une AUTRE origine que celle de discoveryUrl (hôte ou port réécrit par un intermédiaire) Écrire dans discoveryUrl exactement l’origine que Keycloak publie
Invalid parameter: redirect_uri sur la page Keycloak redirectUri ≠ les Valid redirect URIs du client Aligner schéma, hôte (localhost ≠ 127.0.0.1), port et chemin
Retour sur failureRedirect + WARNING unauthorized_client (secret faux) ou invalid_client (client inconnu) Mauvais secret, client absent du realm, ou client public (Client authentication OFF) Recopier le secret de l’onglet Credentials ; passer le client en ON
Retour sur failureRedirect + WARNING OAuth issuer mismatch Keycloak joint par deux adresses différentes : l’émetteur varie Fixer KC_HOSTNAME à l’URL publique complète
Retour sur failureRedirect + WARNING local account exists without a keycloak link Un compte local porte déjà cet identifiant (UserService.ts#UserService.changePassword) — typiquement un sub changé après réimport du realm Rattacher le compte explicitement, ou fixer l’id des utilisateurs dans l’export du realm
API : 401 avec un jeton Keycloak valide Audience absente du jeton : pas de mapper Audience security:keycloak:realm --write puis import partiel ; aud doit contenir la resource de la zone
Session de l’application vivante après une déconnexion chez Keycloak ; Keycloak journalise Some clients have not been logged out Front channel logout ON, ou Backchannel logout URL absente ou injoignable depuis Keycloak Front channel logout OFF ; une URL que Keycloak joint (en développement Linux : serveur à l’écoute au-delà de la boucle locale)

| Démarrage refusé : … est un rôle de PLATEFORME | roleMapping traduit vers un ROLE_NODEFONY_* | Donner ce rôle à la main, ou écrire allowPlatformRoles: true en connaissance de cause | | Le rôle Keycloak n’apparaît pas dans l’application | Rôle du REALM alors que la source est client, ou rôle d’un autre client, ou session ouverte avant le changement | Créer le rôle sur le client de l’application, ou rolesSource: ["realm"] ; se reconnecter |

La cause d’un retour sur failureRedirect n’est jamais montrée au navigateur ; elle est dans le journal du serveur, préfixée oauth2 callback "keycloak" (OAuth2Controller.ts#OAuth2Controller.authorize).

🧪 Tests & couverture#

Type Fichier Ce qu’il prouve
Unitaire security/tests/unit/oauth2Service.test.ts Démarrage refusé sur émetteur absent ou mal formé ; bouton retiré puis rendu quand l’émetteur tombe.
Unitaire security/tests/unit/oauthProviders.test.ts Découverte, refus d’un serveur sans PKCE S256, contrôle des claims de l’ID token.
Unitaire security/tests/unit/providerRoles.test.ts Lecture des claims (client, realm, groupes), rôle inconnu ignoré, refus des rôles de plateforme sauf allowPlatformRoles, recalcul à la connexion et au jeton, aucune écriture sans changement.
Unitaire security/tests/unit/keycloakRealmInput.test.ts La configuration de l’application de dev, dérivée puis fusionnée, rend realm-nodefony.json octet pour octet ; audiences déduites ou écrites, production sans secret, rôles rangés selon rolesSource.
Unitaire security/tests/unit/providerDiagnosis.test.ts Diagnostic : trois verdicts distincts (émetteur, URL de retour, secret) ; un 401 n’accuse le secret que si le client est prouvé connu ; non concluant ≠ échec ; sondes passées par le client du login.
Unitaire security/tests/unit/keycloakRealm.test.ts Fusion : ce que la config dit est réécrit, ce qu’un humain a écrit survit ; mapper d’audience remplacé, jamais doublé.
Unitaire user/tests/unit/providerRoles.test.ts Rôles gérés contre rôles locaux : retrait, ajout, rôle à la main conservé, profil préservé.
Unitaire security/tests/unit/oauthMetadata.test.ts discoveryUrl : lu à cette adresse seule ; émetteur différent refusé ; point de jeton en http admis sur l’origine déclarée seulement ; point d’autorisation toujours en https.
Unitaire nodefony/src/tests/authorizationServer.test.ts La règle elle-même, au cœur : adresse validée, jwks_uri en http admis sur l’origine exacte (port compris), refusé ailleurs.
Unitaire http/nodefony/tests/unit/certificates.test.ts NF_TLS_CA_FILE : une chaîne en un fichier devient N autorités ; fichier absent, vide, ou Node trop ancien → démarrage refusé ; un vrai serveur https signé par l’autorité déclarée devient joignable.
Intégration http/nodefony/tests/integration/oauth2-keycloak.test.ts Contre un vrai Keycloak : flux complet, même compte entre session et jeton d’API, invalid_grant rendu par Keycloak, déconnexion dans les deux sens, jeton de déconnexion forgé refusé ; bob obtient puis perd ROLE_ADMIN (session et jeton) quand le rôle bouge dans le realm, un rôle local survit ; cci obtient ROLE_NODEFONY_ADMIN par allowPlatformRoles ; le diagnostic rend ses trois verdicts sur des branchements faussés (secret, URL de retour, émetteur 127.0.0.1 au lieu de localhost) ; le realm lu par 127.0.0.1 avec discoveryUrl rend un jeton machine, et refuse un émetteur réellement différent.

Le banc d’intégration exige le conteneur et les trois variables (décor KEYCLOAK_GATE de scripts/test/vitest/gates.ts) ; sans eux il est sauté, et la passe le dit en fin de run. Le cas du canal arrière constate en plus que le conteneur JOINT l’application (host.docker.internal) : un serveur de développement sous Linux n’écoute que la boucle locale, le cas est alors sauté et le dit.

Ce qui manque : aucun banc versionné ne joue un Keycloak en KC_HOSTNAME_BACKCHANNEL_DYNAMIC (points internes en http) — le tableau « Ce qu’on observe » a été constaté à la main sur un Keycloak 26.8 jetable, et le banc versionné ne couvre que le cas https. Aucun ne joue non plus un Keycloak derrière un proxy (émetteur variable), ni une rotation des clés du realm pendant une session.

🔗 Pour aller plus loin#