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

Jetons d'un émetteur TIERS — accepter Keycloak, Auth0 ou Entra sans leur céder l'application

stable@nodefony/securitymis à jour 2026-08-24

📍 Documentation › Sécurité › Jetons d’un émetteur tiers

Une application Nodefony sait émettre ses propres jetons (Jetons). Cette page traite du cas inverse : un serveur d’autorisation extérieur — l’annuaire de l’entreprise, Keycloak, Auth0, Entra ID — émet les jetons, et l’application doit décider qui entre. Elle devient alors ce que les normes appellent un serveur de ressources (RFC 6750, RFC 9068).

C’est le mode d’une API appelée par d’autres services, par des agents, ou par une application dont l’authentification est centralisée ailleurs.

Ce que l’application NE délègue pas#

Accepter un émetteur ne veut pas dire lui remettre les clés. Deux décisions restent locales, et ce sont elles qui font la différence entre « intégrer un annuaire » et « en faire l’unique autorité d’accès » :

  1. La liste des émetteurs acceptés est une allowlist (trustedIssuerSchema, config.ts#trustedIssuerSchema). Un jeton dont l’iss n’y figure pas est refusé avant toute requête sortante — l’application ne va pas interroger un émetteur inconnu.
  2. Le sujet du jeton ne devient pas d’office un utilisateur (config.ts#remoteJwtAlgSchema). Par défaut, il doit correspondre à un compte local. Un annuaire d’entreprise vaut pour des milliers de personnes : les accepter toutes parce que leur jeton est valide supprimerait la seconde décision, qui est la raison d’être du pare-feu.

Démarrage rapide#

// nodefony.config.ts
import { defineConfig, use } from "nodefony";

export default defineConfig({
  modules: [
    "@nodefony/http",
    "@nodefony/framework",
    use("@nodefony/security", {
      resourceServer: {
        issuers: [
          {
            issuer: "https://auth.example.com/realms/mon-royaume",
            algorithms: ["RS256"],
          },
        ],
      },
    }),
  ],
});

Cela suffit : les clés publiques de l’émetteur sont découvertes par ses points de métadonnées normalisés (RFC 8414 / OpenID), et le pare-feu accepte désormais un Authorization: Bearer <jeton> émis par ce royaume — à condition que le sujet du jeton corresponde à un compte local.

L’audience n’est pas facultative. Un jeton émis pour une autre application du même annuaire ne doit pas ouvrir celle-ci : c’est le rôle du claim aud (RFC 8707). La vérification l’exige.

Les réglages, et ce qu’ils engagent#

Réglage Défaut Ce qu’il décide
issuers[].issuer — L’allowlist. En https, sans requête ni fragment (RFC 8414 §2).
issuers[].jwksUri découvert Déclaré, aucune découverte n’a lieu — utile pour un émetteur sans métadonnées, ou un démarrage à froid sans requête sortante.
issuers[].algorithms RS256, ES256, EdDSA Allowlist serveur : l’algorithme n’est jamais déduit de l’en-tête du jeton (RFC 8725 §3.1). À restreindre à ce que l’émetteur utilise réellement.
issuers[].typ non exigé at+jwt pour un émetteur conforme RFC 9068.
issuers[].requiredClaims [] Claims dont la présence est exigée, en plus d’iss et aud.
issuers[].subjectMapping prefixed Comment le sub devient un identifiant local — voir l’encadré ci-dessous.
subjectPolicy require require : un compte local est exigé. ephemeral : l’appelant vit le temps de la requête.
ephemeralRoles [] Rôles accordés en mode ephemeral. Vide à dessein.

🔴 subjectMapping — pourquoi le défaut est prefixed#

Un sub n’est unique que dans l’espace de son émetteur (OIDC Core §2). L’identité est donc la paire (émetteur, sujet), jamais le sujet seul.

En mode prefixed (config.ts#trustedIssuerSchema), l’identifiant cherché localement est <issuer>#<sub> : deux émetteurs ne peuvent pas se disputer un compte, et surtout aucun sujet étranger ne peut tomber par hasard sur un identifiant local existant. En mode subject, le sub est cherché tel quel — dans un annuaire où l’utilisateur choisit son identifiant, quelqu’un peut alors se présenter avec sub: "admin" et être rattaché au compte local du même nom.

subject ne se déclare donc que si l’on maîtrise l’espace de noms de cet émetteur : typiquement parce qu’il est cette application, ou parce que ses sujets sont déjà des identifiants locaux.

subjectPolicyqui a le droit d’exister#
  • require (défaut) — le sujet doit correspondre à un compte local (loadUserByIdentifier). Absent, désactivé ou verrouillé : l’accès est refusé. C’est le mode d’une application dont les utilisateurs existent chez elle, l’émetteur ne servant qu’à les authentifier.
  • ephemeral — aucun compte local n’est exigé ni créé ; l’appelant vit le temps de la requête avec les rôles d’ephemeralRoles (config.ts#trustedIssuerSchema). C’est le mode de l’appelant purement machine : un agent, un service. Sans rôle déclaré, il ne passe aucun @IsGranted et n’est autorisé que par ses scopes — qui viennent du jeton, donc bornés par le serveur d’autorisation.

Écrire un rôle dans ephemeralRoles accorde un pouvoir local à quiconque détient un jeton valide pour cette ressource. À faire sciemment, jamais par confort.

Une politique PAR ZONE#

resourceServer.subjectPolicy vaut pour toute l’application. Une zone qui liste external-jwt peut la surcharger — c’est ce qui fait cohabiter une API d’appelants machine et une zone de personnes :

areas: {
  partner: {
    pattern: "^/api/partner",
    stateless: true,
    authenticators: ["jwt", "external-jwt"],
    resource: "https://app.example/api/partner",
    subjectPolicy: "ephemeral",        // l'appelant machine n'a pas de compte local
    ephemeralRoles: ["ROLE_PARTNER"],  // facultatif — sinon : scopes seulement
  },
  staff: {
    pattern: "^/staff",
    authenticators: ["external-jwt"],
    resource: "https://app.example/staff", // suit la globale (« require »)
  },
}

Trois règles, toutes vérifiées au boot ou au rattachement :

Comment ça s’articule#

Le service accessTokenVerifier est posé au conteneur dès qu’il a quelque chose à vérifier : les jetons de l’application elle-même (capacité jwt active, le défaut), et ceux des émetteurs de issuers. Il aiguille sur l’émetteur revendiqué : celui de l’application est jugé par les règles de l’authentificateur jwt (révocation et compte compris), tout autre par cette liste fermée — règle complète et contrôles, dans l’ordre : Ce que la porte vérifie. Capacité jwt coupée ET issuers vide : rien n’est posé, et une porte protégée refuse de servir (503) plutôt que d’accepter des porteurs qu’elle ne sait pas lire. issuers mal configurée : rien n’est posé non plus, jetons de l’application compris.

L’authentificateur external-jwt (authenticatorRegistry.ts) reconnaît les jetons qui le concernent d’après la liste d’émetteurs, puis délègue la vérification au service — qui refait le contrôle sur sa propre liste. La liste sert donc à deux choses : router, et dire dans quel espace de noms lire le sujet.

areas: {
  api: {
    pattern: "^/api",
    stateless: true,
    authenticators: ["external-jwt"],
    resource: "https://api.example.com",
  },
}

Côté appelant — le jeton d’un programme#

Un script, un service partenaire ou un agent obtient son jeton en son propre nom par le flux client_credentials (RFC 6749 §4.4) : pas d’utilisateur, pas de navigateur, un client confidentiel et son secret. Le framework le porte, pour qu’aucun appelant ne recopie la plomberie OAuth :

import { requestClientCredentialsToken } from "@nodefony/security";
// `env.ts` est le seul lecteur de l'environnement : le secret y est déclaré (NF_KEYCLOAK_MACHINE_SECRET).
import { env } from "../../env";

const tokens = await requestClientCredentialsToken({
  issuer: "https://sso.example.com/realms/acme", // le point de jeton en est DÉCOUVERT (RFC 8414)
  clientId: "acme-machine",
  clientSecret: env.NF_KEYCLOAK_MACHINE_SECRET ?? "",
  resource: "https://app.example/api/partner", // audience visée (RFC 8707), facultative
});
await fetch("https://app.example/api/partner/whoami", {
  headers: { Authorization: `Bearer ${tokens.accessToken()}` },
});

Ce que l’application publie d’elle-même#

Une ressource protégée doit dire où obtenir un jeton valable pour elle. C’est l’objet des métadonnées de ressource protégée (RFC 9728), servies par l’application, et de l’en-tête WWW-Authenticate renvoyé sur un refus. Un client conforme y trouve seul l’émetteur à interroger et l’audience à demander.

⚠️ Pièges#

📖 Lexique#

Terme Ce que ça désigne ici
Émetteur (iss) Le serveur d’autorisation qui a signé le jeton. Identifiant en https, sans requête ni fragment.
Audience (aud) Pour QUI le jeton a été émis. Un jeton destiné à une autre application ne doit pas ouvrir celle-ci.
Sujet (sub) Qui est le porteur, dans l’espace de noms de son émetteur — jamais unique en soi.
JWKS Le jeu de clés publiques de l’émetteur, qui permet de vérifier la signature sans secret partagé.
Serveur de ressources Le rôle que joue l’application : elle vérifie des jetons qu’elle n’a pas émis (RFC 6750).
Scope Ce que le serveur d’autorisation a autorisé. Distinct d’un rôle, qui est une décision locale.

🧪 Tests & couverture#

Les chiffres exacts vivent dans la carte de l’aperçu, régénérée depuis vitest — jamais figés ici.

Pour aller plus loin#