-
Notifications
You must be signed in to change notification settings - Fork 0
FR Identity Federation
La fédération relie l'identité d'entreprise (Entra / AD / ADFS / Okta / Google...) au modèle interne
rôles + organigramme. Trois paquets couvrent le sujet : @kengela/iam-mapping (normalisation +
mapping, pur), @kengela/scim-server (cœur SCIM 2.0), @kengela/adapter-directory-ldap
(connecteur AD/LDAP).
Note sur
DirectoryProfile. Cette page utilise leDirectoryProfileriche de@kengela/iam-mapping(email, firstName, lastName, attributs, groupes, claims), distinct du type homonyme minimal de@kengela/contracts.
Quelle que soit la source, chaque adapter projette le payload IdP vers une seule cible normalisée. L'application ne raisonne jamais sur la forme brute d'un IdP : une fois le profil normalisé, le mapping de rôles et la classification marchent tels quels, quel que soit le sens de la synchro.
| Source | Fonction de projection | Entrée |
|---|---|---|
| OIDC (Entra / Okta / Keycloak) | profileFromOidcClaims(claims, map?) |
claims du jeton |
| SCIM 2.0 | profileFromScim(body, map?) |
corps SCIM (core + enterprise) |
| SAML 2.0 (ADFS / Entra / Okta) | profileFromSaml(assertion) |
assertion normalisée (nameID + attributs) |
| LDAP / Active Directory | profileFromLdap(entry) |
entrée LDAP (DN + attributs) |
| Microsoft Graph | profileFromGraph(user) |
utilisateur Graph /users
|
| Google Workspace | profileFromGoogle(user) |
utilisateur Admin SDK Directory |
Il existe aussi profileFromParts(...) (reconstruire un profil depuis un état persisté) et
projectScimUser(user) (projeter un KengelaScimUser typé).
Le DirectoryProfile normalisé :
interface DirectoryProfile {
readonly email: string;
readonly externalId: string | null; // sub OIDC / nameID SAML / externalId SCIM
readonly firstName: string | null;
readonly lastName: string | null;
readonly displayName: string | null;
readonly attributes: DirectoryAttributes; // department, title, manager, phoneNumber, ...
readonly groups: readonly string[]; // groupes de sécurité (source du mapping)
readonly claims: Readonly<Record<string, unknown>>; // bruts, pour règles avancées
}Chaque source accepte une carte d'attributs optionnelle par tenant (OidcAttributeMap,
ScimAttributeMap, SamlAttributeMap, LdapAttributeMap). L'admin peut surcharger, champ par
champ, le claim/attribut/chemin lu ; à défaut, une liste de candidats usuels est essayée (le nom
fourni prime, puis les défauts). Sans surcharge, l'extraction est identique au comportement
historique.
import { profileFromOidcClaims } from '@kengela/iam-mapping';
const profile = profileFromOidcClaims(idTokenClaims, {
title: 'jobTitle', // cet IdP met le poste dans `jobTitle`
groups: 'roles', // et les groupes dans `roles`
});Les défauts sont des sources uniques de vérité exportées (ex. LDAP_AD_ATTRIBUTE_DEFAULTS,
SAML_DEFAULT_ATTRIBUTE_KEYS, SCIM_DEFAULT_ATTRIBUTE_KEYS, OIDC_DEFAULT_ATTRIBUTE_KEYS), pour
alimenter aussi la saisie assistée côté admin (jamais en dur dans l'UI).
evaluateMappings(profile, rules) traduit un DirectoryProfile en clés de rôle et directives de
rattachement organisationnel, selon des règles configurables par tenant (jamais en dur).
import { evaluateMappings, type IdpMappingRule } from '@kengela/iam-mapping';
const rules: IdpMappingRule[] = [
{
id: 'validators',
priority: 0,
all: [{ source: 'GROUP', op: 'iequals', value: 'Validateurs' }],
assignRoleKeys: ['VAL'],
},
{
id: 'finance-dept',
priority: 10,
any: [{ source: 'ATTRIBUTE', key: 'department', op: 'iequals', value: 'Finance' }],
assignRoleKeys: ['FIN'],
orgUnit: { by: 'code', fromAttribute: 'costCenter' },
stopOnMatch: true,
},
];
const result = evaluateMappings(profile, rules);
// { roleKeys: ['VAL', 'FIN'], orgUnitDirectives: [...], matchedRuleIds: [...] }Comportement (déterministe, fail-closed) :
- Évaluation par priorité croissante, départage stable par
id. -
all= ET logique,any= OU logique. Une règle vide (niallniany) ne matche jamais. - Les rôles s'accumulent (union) ; les directives d'unité sont collectées par ordre de priorité ;
stopOnMatchcourt-circuite le reste. - Opérateurs (
MatchOp) :equals,iequals,contains,matches,in,present.
L'opérateur matches compile une regex fournie par l'admin. Elle passe par safeRegexTest : bornes
de longueur (source 200, entrée 1024) + rejet des quantificateurs imbriqués ((a+)+, (.+)+...).
Un motif suspect ou trop long → fail-closed (la condition ne matche pas), jamais d'évaluation non
bornée.
import { compileSafeRegex, safeRegexTest, SAFE_REGEX_LIMITS } from '@kengela/iam-mapping';@kengela/iam-mapping définit un superset SCIM 2.0 (KengelaScimUser) qui va au-delà d'un seul
IdP : cœur RFC 7643 + extension enterprise + richesse Okta/Entra/Google. Chaque application pioche le
sous-ensemble utile ; la lib ne fige jamais la liste (bag extensions).
import {
SCIM_SCHEMA_CORE_USER, // urn:ietf:params:scim:schemas:core:2.0:User
SCIM_SCHEMA_ENTERPRISE_USER, // ...:extension:enterprise:2.0:User
SCIM_SCHEMA_GROUP, // ...:core:2.0:Group
KENGELA_SCIM_ATTRIBUTE_PATHS, // registre des chemins portés (source unique)
projectScimUser,
type KengelaScimUser,
} from '@kengela/iam-mapping';@kengela/scim-server fournit un cœur SCIM sans HTTP : un port de persistance ScimStore, des
handlers purs (store, requête parsée) → réponse, la sérialisation/parsing, la découverte et
la validation. Un adapter (NestJS, Express...) résout le tenant, parse le corps, appelle un
handler et sérialise la ScimResponse en application/scim+json.
import {
handleUsersPost,
handleUsersPostStrict,
handleUsersGet,
handleUsersList,
handleUsersPatch,
handleUsersPut,
handleUsersDelete,
handleGroupsPost,
handleGroupsGet,
handleGroupsList,
handleGroupsPatch,
handleGroupsPut,
handleGroupsDelete,
type ScimStore,
type ScimRequest,
} from '@kengela/scim-server';
const response = await handleUsersPost(store, {
tenantId: 't1',
body: idpPushedUserJson,
});
// { status: 201, body: { ...ressource SCIM... } }Doctrine (RFC 7644), prouvée par test :
-
Provisioning réconcilié par e-mail, insensible à la casse :
handleUsersPostest idempotent (existant → 200 sans doublon, nouveau → 201). -
Mode strict (
handleUsersPostStrict) :userNamedéjà présent → 409uniqueness(pour le validateur Microsoft Entra, qui attend le rejet du doublon). -
Déprovisionnement = désactivation :
handleUsersDeletedésactive (active=false), ne supprime jamais physiquement (204 si effectué). -
Filtres bornés :
userName eq/externalId eqsupportés + pagination ; un filtre non supporté rend une liste vide (jamais d'erreur) ; les filtres sont bornés (anti-ReDoS). - PATCH (§3.5.2) : op inconnue ignorée, path forgé borné.
-
Isolation tenant : chaque handler est borné au
tenantId(404 sur un accès cross-tenant).
Le port ScimStore (à implémenter par l'app, ex. sur Prisma) expose exactement ce dont les handlers
ont besoin :
interface ScimStore {
getUser(tenantId, id): Promise<ScimUserRow | null>;
findUserByEmail(tenantId, email): Promise<ScimUserRow | null>; // réconciliation insensible à la casse
listUsers(tenantId, options): Promise<ScimListPage<ScimUserRow>>; // totalResults = total AVANT pagination
createUser(tenantId, input): Promise<ScimUserRow>;
replaceUser(tenantId, id, input): Promise<ScimUserRow | null>;
patchUser(tenantId, id, patch): Promise<ScimUserRow | null>;
deactivateUser(tenantId, id): Promise<ScimUserRow | null>; // désactive, ne supprime jamais
// ... Groups : getGroup / listGroups / createGroup / replaceGroup / patchGroup / deleteGroup
}Le validateur Microsoft Entra interroge ces endpoints pour se configurer. Handlers purs, sans store :
import {
handleServiceProviderConfig, // GET /ServiceProviderConfig
handleResourceTypes, // GET /ResourceTypes[/:id]
handleSchemas, // GET /Schemas[/:id]
} from '@kengela/scim-server';
const cfg = handleServiceProviderConfig();
// { status: 200, body: { patch: {supported:true}, filter: {supported:true, maxResults}, bulk:{supported:false}, ... } }La configuration annonce les capacités réelles : PATCH supporté, filtre supporté (borné) ;
bulk / sort / etag / changePassword non supportés ; authentification par jeton porteur OAuth. Les
schemaDefinitions() décrivent exactement ce que KengelaScimUser sait porter, et sont la source de
vérité consommée par la validation.
validateScimUser / validateScimGroup contrôlent une ressource contre le schéma Kengela, à
l'entrée (corps poussé par l'IdP) comme à la sortie (round-trip). Fail-closed, sans any :
import { validateScimUser } from '@kengela/scim-server';
const { valid, errors } = validateScimUser(pushedBody);
if (!valid) {
// errors = liste EXHAUSTIVE des écarts (schemas manquant, userName requis, types...)
}Contrôles : schemas présent / non vide / URNs reconnues ; attributs requis présents (userName
pour User, displayName pour Group) ; types scalaires corrects ; multi-valués bien formés.
@kengela/adapter-directory-ldap est le jumeau « pull » des connecteurs Graph/Google/SCIM. Il se lie
en LDAPS (TLS vérifié par défaut), parcourt l'annuaire par recherche paginée, et renvoie des
entrées normalisées directement consommables par profileFromLdap. Aucun mapping de rôles ici :
l'adapter ne fait que parler LDAP.
import { LdapDirectorySource } from '@kengela/adapter-directory-ldap';
const source = new LdapDirectorySource({
url: 'ldaps://dc.corp.local:636',
bindDN: 'CN=svc-read,OU=Service,DC=corp,DC=local',
bindPassword: vaultSecret, // jamais journalisé
baseDN: 'OU=Users,DC=corp,DC=local',
// userFilter, attributes, pageSize, maxUsers, tlsRejectUnauthorized : défauts AD surchargeables
});
const entries = await source.fetchEntries(); // LdapEntryParts[]
const records = LdapDirectorySource.toRecords(entries); // { profile, active }[]
const healthy = await source.checkConnection(); // true/false, sans fuiter le secretPoints durcis (prouvés par test) :
-
TLS vérifié par défaut (
tlsRejectUnauthorized: true) ; ne le désactiver que pour un annuaire de test. - Le mot de passe de bind n'est jamais journalisé (ce module ne journalise rien).
-
unbind()est garanti même en cas d'échec (blocfinally). - Le plafond
maxUsersest appliqué ;checkConnectionavale l'erreur sans fuiter le secret. - Désactivation détectée via
userAccountControl(bitACCOUNTDISABLE0x2) →accountActiveFromLdap.
Surface narrow du client : LdapClientLike ne déclare que bind / search / unbind (lecture
seule ; aucune écriture d'annuaire). Un vrai Client de ldapts la satisfait structurellement, et
un fake en mémoire aussi (tests).
Dette (DEBT LDAP #5). L'adapter transmet le
filterverbatim (aucune injection introduite), mais n'expose pas encore de helperescapeLdapFilterValue(): une app qui composerait un filtre depuis une entrée utilisateur non échappée resterait exposée à l'injection de filtre LDAP côté appelant.
🇬🇧 English
- Home
- Getting started
- Architecture
- Authorization
- Authentication
- Identity federation
- Compliance & PII
- NestJS integration
- Developing an adapter
- Security
Recipes
- NestJS + native + Prisma
- better-auth
- SCIM / Entra
- LDAP / AD
- RBAC / ABAC
- PII / GDPR
- Combo: better-auth + PII
- Combo: SCIM/Entra + authz
- Combo: full stack
🇫🇷 Français
- Accueil
- Prise en main
- Architecture
- Autorisation
- Authentification
- Fédération d'identité
- Conformité & PII
- Intégration NestJS
- Développer un adapter
- Sécurité
Recettes