Nodefony
Framework Node.js fullstack — HTTP & WebSocket, même contexte

OAuth 2.0 — social login (Authorization Code + PKCE, posture 2.1)

stable@nodefony/securitymis à jour 2026-09-07

Ton utilisateur clique « Se connecter avec GitHub », part chez GitHub, revient — et se retrouve connecté à ton application. Nodefony orchestre ce voyage avec la posture OAuth 2.1 (RFC 9700) : Authorization Code, PKCE, state anti-CSRF, iss anti-mix-up. Point clé : aucun jeton n’atteint le navigateur — le retour produit une session BFF, exactement la même qu’un login par mot de passe. Ancré sur OAuth2Service (oauth2.ts:55) et le controller BFF OAuth2Controller (OAuth2Controller.ts:81).

📍 DocumentationSécuritéOAuth2

🧠 Le modèle mental — deux allers-retours, un secret qui ne bouge pas#

Le social login n’est pas « GitHub nous donne l’utilisateur ». C’est deux voyages :

  1. le navigateur va demander un accord chez le fournisseur et revient avec un ticket à usage unique (le code) ;
  2. ton serveur seul échange ce ticket contre des jetons, sur un canal serveur-à-serveur.

L’analogie : le code est un ticket de vestiaire confié au client. Le manteau ne s’échange qu’au comptoir, sur présentation du ticket et du talon que le comptoir avait gardé (le code_verifier PKCE). Voler le ticket dans la poche du client ne suffit pas.

Deux propriétés se lisent sur ce schéma. Le secret d’échange (clientSecret, code_verifier) ne quitte jamais le serveur. Et le résultat n’est pas un jeton exposé au JavaScript : c’est un cookie de session opaque, révocable côté serveur.

📖 Lexique#

Terme Sens
OAuth 2.0 Protocole de délégation d’accès (RFC 6749). Ici détourné pour prouver une identité.
OIDC OpenID Connect : couche d’identité au-dessus d’OAuth ; ajoute l’ID token signé.
IdP Identity Provider — le fournisseur qui authentifie (Google, GitHub, Keycloak…).
Authorization Code Le flux où le serveur échange un code à usage unique contre des jetons. Jamais côté client.
PKCE Proof Key for Code Exchange (RFC 7636) : lie la demande et l’échange (anti-interception du code).
code_verifier Le secret aléatoire gardé en session ; son empreinte (code_challenge) part avec la demande.
state Jeton anti-CSRF porté à l’aller et au retour, comparé côté serveur (RFC 9700).
iss Émetteur renvoyé au callback ; doit correspondre à celui attendu (anti-mix-up, RFC 9207).
Mix-up Attaque où un code émis par un IdP est présenté au callback d’un autre IdP.
ID token JWT signé par l’IdP portant les claims d’identité (sub, email, name…).
sub Subject : identifiant stable du compte chez le fournisseur (jamais l’e-mail).
Claim Une donnée d’identité attestée par l’IdP (couple clé/valeur dans l’ID token).
BFF Backend For Frontend : l’identité vit en session serveur, pas en jeton exposé au JS.
Shadow User La ligne locale créée à l’image du compte externe — c’est elle qui porte les rôles.
JIT Just In Time : le Shadow User est créé au premier login, pas par un import préalable.
Découverte L’IdP publie ses points d’entrée (RFC 8414) : son seul émetteur suffit à le décrire, aucune URL en dur.

Qu’est-ce que c’est ? — et quelles failles ça ferme#

Déléguer le login à Google ou GitHub, c’est ne plus stocker de mots de passe : plus de fuite de hachages, plus de réinitialisation à gérer, un utilisateur qui n’invente pas un énième secret.

Mais OAuth mal implémenté est une fabrique à comptes usurpés. Quatre failles classiques, et ce qui les ferme ici :

❗ Important

Le fournisseur social te dit qui est la personne. Il ne te dit rien de ses droits. Se connecter avec le compte Google d’un administrateur de Google ne rend administrateur de rien chez toi. Les rôles viennent de la ligne locale — voir la section Shadow User.

La vision Nodefony — un service sans transport, une session BFF#

Trois partis pris, tous vérifiables au code.

Le service ne touche ni HTTP ni session. OAuth2Service rend à l’appelant les éléments à persister (url, state, codeVerifier) et un simple { identifier } en sortie (IOAuthAuthorization, oauth2.ts:21). Conséquence pratique : la logique OAuth se teste sans serveur, comme AuthFlow. Le transport (cookies, redirections 302) vit dans le controller BFF.

Le login social finit exactement comme un login classique. Le callback appelle AuthFlow.establishSessionFor() (authFlow.ts:215), qui re-résout l’identité, vérifie que le compte est actif, régénère l’ID de session (anti-fixation, session.regenerateId(), authFlow.ts:388) et journalise l’événement d’audit. Il n’existe aucun authenticator oauth2 dans la chaîne du firewall : après le retour, c’est l’authenticator session qui identifie chaque requête, comme après un mot de passe.

Coût nul quand on ne s’en sert pas. Aucune dépendance tierce : le client OAuth 2.0 est écrit dans le module (oauth2Client.ts:207), et jose — seul recours externe, pour lire les claims de l’ID token — est importé paresseusement. Les fournisseurs sont construits au premier login puis mémoïsés (OAuth2Service.#resolveProvider(), oauth2.ts:190) : c’est là, une seule fois par processus, que les points d’entrée d’un émetteur OIDC sont découverts. Les routes ne sont montées que si le service existe (framework/index.ts:379) : sans social login configuré, la surface HTTP est 404, pas « désactivée ».

Au boot, la config est validée et les fournisseurs configurés sont confrontés au registre : un nom inconnu produit un WARNING, pas un échec fatalOAuth2Service.#build() confronte les noms configurés à listOAuthProviders() (oauth2.ts:86-95) et le reste de l’application démarre, le bouton correspondant n’apparaît simplement pas.

🚀 Démarrage rapide#

Les secrets entrent par env.ts, la config les branche#

Un fournisseur n’est monté que si ses deux secrets sont présents : pas de bouton mort sur l’écran de login quand la variable manque.

// 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";

export const env = defineEnv({
  GITHUB_CLIENT_ID: envString({ optional: true }),
  GITHUB_CLIENT_SECRET: envString({ optional: true }),
  // Base des callbacks : doit correspondre EXACTEMENT à l'URL enregistrée chez
  // le fournisseur (RFC 9700 — comparaison de chaînes, pas de préfixe).
  OAUTH_REDIRECT_BASE: envString({ default: "https://localhost:5152" }),
});

export default defineConfig<typeof env>((ctx) => ({
  modules: [
    "@nodefony/http",
    "@nodefony/framework",
    use("@nodefony/security", {
      oauth2: {
        // Rôles posés à la CRÉATION du compte local, jamais réécrits ensuite.
        defaultRoles: ["ROLE_USER"],
        allowSignup: true, // false = un compte local déjà lié est exigé
        successRedirect: "/",
        failureRedirect: "/login?error=oauth",
        providers: {
          // Secrets absents → fournisseur non monté, bouton non affiché.
          ...(ctx.env.GITHUB_CLIENT_ID && ctx.env.GITHUB_CLIENT_SECRET
            ? {
                github: {
                  clientId: ctx.env.GITHUB_CLIENT_ID,
                  clientSecret: ctx.env.GITHUB_CLIENT_SECRET,
                  redirectUri: `${ctx.env.OAUTH_REDIRECT_BASE}/nodefony/security/api/oauth2/github/callback`,
                },
              }
            : {}),
        },
      },
    }),
  ],
}));

Les routes sont FOURNIES — tu n’écris aucun controller#

mountOAuth2Routes() (OAuth2Controller.ts:208) monte trois routes sous /nodefony/security/api/oauth2 (OAuth2Controller.ts:187), et seulement si le service oauth2 est présent (framework/index.ts:379) :

Route Rôle
GET …/providers Noms des fournisseurs opérationnels — l’UI n’affiche que ceux-là.
GET …/{provider}/authorize Démarre le flux : pose l’état en session, 302 vers le fournisseur.
GET …/{provider}/callback Valide, échange, provisionne, ouvre la session BFF, 302.

Ton écran de login n’a donc qu’un lien à poser :

<a href="/nodefony/security/api/oauth2/github/authorize"
  >Se connecter avec GitHub</a
>

⚠️ Attention

Ces routes portent bypassFirewall: true (OAuth2Controller.ts:236) — elles sont le mécanisme d’authentification : l’utilisateur est anonyme pendant tout l’aller-retour. Les protéger créerait un interblocage (il faudrait être connecté pour pouvoir se connecter). La session anonyme ne porte que state/code_verifier, et son ID est régénéré à la promotion.

Ce qu’on observe#

# 1) Démarrage : 302 vers le fournisseur + cookie de transit + state dans l'URL
curl -si -c /tmp/jar http://localhost:5151/nodefony/security/api/oauth2/github/authorize | head -3
# HTTP/1.1 302 Found
# Location: https://github.com/login/oauth/authorize?...&state=8f2c…
# Set-Cookie: nodefony-sessid=…; HttpOnly; SameSite=Lax

# 2) Retour du fournisseur (c'est le NAVIGATEUR qui suit ce lien) → session BFF
curl -si -b /tmp/jar -c /tmp/jar \
  "http://localhost:5151/nodefony/security/api/oauth2/github/callback?code=…&state=8f2c…" | head -2
# HTTP/1.1 302 Found
# Location: /

# 3) L'identité est résolue comme après un login classique
curl -s -b /tmp/jar http://localhost:5151/nodefony/security/api/auth/me
# {"user":{"username":"jane@example.com","roles":["ROLE_USER"]}}

# 4) Ce que l'UI de login interroge pour n'afficher que des boutons vivants
curl -s http://localhost:5151/nodefony/security/api/oauth2/providers
# {"providers":["github"]}

Séquence identique prouvée de bout en bout sur serveur réel par oauth2-flow.test.ts (6 cas).

🏗️ Le flux, étape par étape#

Étape 1 — createAuthorization(provider)#

OAuth2Service.createAuthorization() (oauth2.ts:139) fabrique trois choses :

  1. un state aléatoire (anti-CSRF) ;
  2. un code_verifierseulement si le fournisseur pratique PKCE (usesPkce, oauth2.ts:143-145) ; null sinon (GitHub) ;
  3. l’URL d’autorisation construite par l’adaptateur du fournisseur, avec les scopes effectifs (ceux de la config, sinon les scopes par défaut du fournisseur, oauth2.ts:216).

Le controller pose les trois valeurs en session, persiste (session.save() — pas seulement en mémoire, OAuth2Controller.ts:105-108), puis redirige en 302.

Étape 2 — le retour, validé avant tout appel réseau#

OAuth2Controller.callback() (OAuth2Controller.ts:132) travaille dans cet ordre, et l’ordre est la défense :

  1. lire l’état de session, puis l’invalider immédiatement (OAuth2Controller.ts:126-129) — le state est à usage unique : un rejeu du même retour échoue, même avec le bon cookie ;
  2. comparer : code et state présents, state reçu ≡ state attendu, et fournisseur du callback ≡ fournisseur démarré (OAuth2Controller.ts:137-145). Un seul écart → 302 vers failureRedirect, sans jamais contacter le fournisseur ;
  3. seulement ensuite, exchangeAndProvision().

Étape 3 — exchangeAndProvision(provider, code, verifier, iss)#

OAuth2Service.exchangeAndProvision() (oauth2.ts:162) enchaîne :

  1. anti-mix-up — si le fournisseur annonce un émetteur attendu, l’iss reçu doit correspondre, et un iss absent est un rejet, pas une tolérance (oauth2.ts:170-174) ;
  2. échange du code sur le canal serveur, avec le code_verifier (validateAuthorizationCode, oauth2.ts:181), puis lecture du profil (fetchProfile, oauth2.ts:176) ;
  3. provisionnement du Shadow User avec la politique effective — rôles par défaut surchargeables par fournisseur (oauth2.ts:180-181), allowSignup global (oauth2.ts:182-185).

Toute erreur de cette étape est convertie en échec uniforme par le controller (302 failureRedirect, OAuth2Controller.ts:157-160) : le client ne distingue pas un iss invalide d’un échange refusé ou d’un signup interdit.

🧑‍⚖️ Le Shadow User — l’identité locale, et pourquoi OAuth n’accorde aucun droit#

Nodefony ne « connecte pas un compte Google ». Il crée et retrouve une ligne locale liée au compte externe : le Shadow User. C’est cette ligne qui porte l’identifiant, les rôles, l’état actif/verrouillé — donc tout ce dont l’autorisation a besoin.

Le contrat s’appelle IOAuthUserProvisioner (IOAuthUserProvisioner.ts:61) ; l’implémentation par défaut est UserService.provisionOAuthUser() (UserService.ts:306), en find-or-create :

Situation au retour du fournisseur Comportement
Lien social déjà connu Le compte existant est rendu tel quel — rien n’est créé, rien n’est réécrit.
Lien inconnu, allowSignup: true Création JIT : password: null, rôles = defaultRoles, lien social persisté.
Lien inconnu, allowSignup: false Échec fail-closed (UserService.ts:363) — un compte lié est exigé.
E-mail identique à un compte local Aucune liaison automatique — un compte SÉPARÉ est créé.
Même providerId chez deux fournisseurs Comptes séparés (le couple provider + providerId fait la clé).

Pourquoi l’e-mail ne lie jamais automatiquement un compte#

C’est le point le plus contre-intuitif, et c’est une décision de sécurité. Si un compte externe dont l’e-mail vaut admin@ton-domaine.fr liait automatiquement l’administrateur local, il suffirait de créer un compte chez un fournisseur laxiste avec cette adresse pour prendre le compte admin. La liaison par e-mail est donc refusée y compris quand le fournisseur certifie l’adresse (emailVerified reste informatif, IOAuthUserProvisioner.ts:24).

Conséquence assumée : l’utilisateur qui avait un mot de passe et clique « avec GitHub » obtient un second compte. Le rattachement d’un compte externe à un compte existant est une action explicite, faite utilisateur déjà connecté — jamais un effet de bord du login.

L’identifiant du compte créé dérive de l’e-mail si le fournisseur en donne un, sinon d’une clé préfixée provider:providerId — jamais de collision entre fournisseurs (UserService.ts:325-326).

Les rôles sont posés à la création, et plus jamais#

defaultRoles s’applique au moment du create (UserService.ts:348). Un second login n’écrase rien : promouvoir quelqu’un dans ta base reste effectif, et modifier defaultRoles en config ne repeint pas les comptes existants. C’est la traduction de la règle « OAuth = authentification, pas autorisation » (oauth2.ts:178-181, config.ts:822-827).

💡 Astuce

Un fournisseur social ne doit jamais figurer dans le chemin d’obtention d’un rôle privilégié. Le schéma de rôles de Nodefony distingue déjà ROLE_NODEFONY_* (plateforme) et ROLE_* (applicatif) — voir authorization.

Brancher sa propre politique#

Le provisioner est le service users s’il implémente la capability, détecté par duck-typing (OAuth2Service.#resolveProvisioner(), oauth2.ts:224-231). S’il ne l’implémente pas, le login échoue — jamais de création silencieuse par défaut. Une application qui veut sa propre politique (quota d’inscriptions, allowlist de domaines e-mail, rattachement à un tenant) implémente provisionOAuthUser() sur son service users : le profil normalisé IOAuthProfile (IOAuthUserProvisioner.ts:12) lui donne provider, providerId, email, emailVerified, name et la charge brute raw.

🧩 Fournisseurs — catalogue et extension#

Un fournisseur est un adaptateur qui implémente IOAuthProvider (IOAuthProvider.ts:21) : il masque les divergences (PKCE ou non, profil par ID token ou par appel d’API) derrière un contrat unique. Quatre sont livrés, résolus par nom via le registre oauthProviderRegistry.ts:50.

Nom Famille PKCE iss vérifié Profil lu depuis Scopes par défaut
google OIDC accounts.google.com ID token (claims) openid, profile, email
keycloak OIDC self-hosted URL du realm (config) ID token (claims) openid, profile, email
oidc OIDC générique émetteur (config) ID token (claims) openid, profile, email
github OAuth simple — (non émis) API REST /user read:user, user:email
googleOIDC, le cas nominal#

Construit par le helper générique createOidcProvider() (oidc.ts:103) : PKCE systématique (usesPkce: true, oidc.ts:111), émetteur figé https://accounts.google.com (oauthProviderRegistry.ts:80). Ses points d’entrée ne sont pas écrits en dur : ils sont demandés à l’émetteur (RFC 8414, cf. « Découverte » plus bas). Le profil se lit dans l’ID token — claims standard sub, email, email_verified, name (oidc.ts:134), après les contrôles obligatoires d’OpenID Connect Core §3.1.3.7 : iss, aud, exp, et un sub non vide (assertIdTokenClaims(), oidc.ts:132). Pas d’identifiant stable, pas d’identité.

keycloakOIDC self-hosted, l’émetteur vient de ta config#

Même helper, mais l’issuer (URL du realm) sert à la fois à découvrir les points d’entrée et à valider l’iss (oauthProviderRegistry.ts:85). Il est donc obligatoire : sans lui, la fabrique lève au premier login avec un message explicite.

oidcn’importe quel serveur OpenID Connect#

La même mécanique, sans nom de marque : l’entrée oidc (oauthProviderRegistry.ts:89) prend l’émetteur de sa configuration et n’a besoin de rien d’autre. C’est elle qui rend inutile une classe par fournisseur.

Le paramètre iss — une règle à TROIS états, pas deux#

La RFC 9207 ajoute un paramètre iss à la réponse d’autorisation, pour qu’un client branché sur plusieurs fournisseurs ne confonde pas leurs réponses. Mais elle ne l’impose pas à tous : son §2.4 demande au client d’extraire iss « if the parameter is present », et son §2.3 fait ANNONCER ce support par les métadonnées de l’émetteur (authorization_response_iss_parameter_supported).

D’où trois cas, et non deux :

Le serveur l’annonce iss reçu Verdict
oui absent refus — il a promis, il n’a pas tenu
oui ou non présent et discordant refus
non absent on continue — le serveur est conforme

Exiger iss d’un serveur qui n’a jamais promis de l’émettre reviendrait à refuser un serveur conforme (Microsoft Entra n’annonce pas ce support). Ce n’est pas un relâchement : la défense anti-mix-up principale est ailleurs — chaque fournisseur a son URL de redirection propre (…/{provider}/callback) et le flux vérifie que le fournisseur de retour est celui qui a démarré, ce que la RFC 9700 §4.4.2.2 donne comme la protection de référence. iss est la seconde ceinture.

La politique est portée par le fournisseur (issuerPolicy, IOAuthProvider.ts:61) et remplie par la découverte ; elle vaut null pour un fournisseur non-OIDC, qui ne relève pas de cette défense.

Découverte des points d’entrée (RFC 8414)#

Aucune URL de fournisseur n’est écrite en dur — sauf GitHub, qui ne publie pas de métadonnées. Les points d’entrée sont demandés à l’émetteur, une seule fois par processus, au premier login.

Cette règle n’est pas réécrite ici : la normalisation de l’émetteur, l’ordre normatif des URL bien connues (§3.1 : insertion oauth → insertion oidc → ajout oidc) et l’égalité stricte du §3.3 vivent dans le cœur (nodefonysrc/oauth/authorizationServer.ts), qui s’en sert aussi pour PUBLIER nos propres métadonnées. metadata.ts n’ajoute que le transport : requête bornée, sans redirection suivie, avec un délai d’attente (discoverAuthorizationServer(), metadata.ts:126).

Deux refus valent d’être connus. Un document dont l’issuer diffère de celui demandé est rejeté sans se rabattre sur l’URL suivante — se rabattre masquerait un document hostile derrière un 404. Et un émetteur qui annonce ses méthodes PKCE sans y mettre S256 est refusé : lui envoyer un défi donnerait l’illusion de PKCE.

ℹ️ Note

Microsoft Entra : un locataire nommé (…/{tenant-id}/v2.0) se découvre normalement. Les points d’entrée common et organizations, eux, publient un issuer contenant le gabarit littéral {tenantid} — l’égalité du §3.3 le refuse, à raison. Le multi-locataire demande donc un adaptateur dédié, pas le builtin.

githubOAuth simple, l’archétype non-OIDC#

Pas de PKCE, pas d’ID token, pas d’iss (usesPkce: false, issuerPolicy: null, github.ts:43-44) : ici, la défense anti-CSRF repose entièrement sur le state. Le profil vient de l’API REST /user (createGithubProvider(), github.ts:34). Subtilité GitHub : l’e-mail primaire est souvent privé — l’adaptateur bascule alors sur /user/emails et n’accepte emailVerified que si GitHub le certifie (github.ts:68-77).

Enregistrer le sien — sans éditer le cœur#

Tout serveur OpenID Connect conforme est déjà supporté — Auth0, Okta, Authentik, Entra mono-locataire… — sans une ligne de code propre. Le builtin oidc suffit quand il n’y en a qu’un ; pour en nommer plusieurs, registerOAuthProvider() (oauthProviderRegistry.ts:56) au chargement de ton module (avant le onBoot du service) :

import {
  registerOAuthProvider,
  createDiscoveredOidcProvider,
} from "@nodefony/security";

// Le nom sert de clé de configuration ET de `provider` du Shadow User ;
// l'émetteur vient de la config (`oauth2.providers.microsoft.issuer`).
registerOAuthProvider("microsoft", (ctx) =>
  createDiscoveredOidcProvider("microsoft", ctx),
);

La fabrique reçoit IOAuthProviderContext (oauthProviderRegistry.ts:24) : les secrets et l’URL de callback issus de la config, rien d’autre. Elle peut être asynchrone — découvrir un émetteur est une opération de construction, faite une fois par processus.

Un fournisseur qui n’est pas OIDC (pas de métadonnées, pas d’ID token) demande un adaptateur : le protocole vient de OAuth2Client, la fabrique ne fait que lire le profil. C’est une quarantaine de lignes — github.ts en est le modèle.

Un fournisseur qui n’est pas OIDC (pas d’ID token, profil lu à son API) s’écrit comme GitHub (createGithubProvider(), github.ts:40) : OAuth2Client porte le protocole, la fabrique ne fait que le mapping du profil. Exemple sans réseau dans le dépôt : src/modules/test/nodefony/secure/oauthTestProvider.ts.

⚙️ Configuration#

Section oauth2 du schéma Zod (config.ts:990), branchée sur la config du module (config.ts:990). Table dérivée du schéma — les défauts sont ceux du code.

Option Type Défaut Effet
enabled booléen true Coupe le social login ; les routes ne montent pas.
defaultRoles liste de rôles ["ROLE_USER"] Rôles du Shadow User à la création (config.ts:989).
allowSignup booléen true false = compte préexistant lié exigé (config.ts:1015).
successRedirect chemin / Où revient l’utilisateur après succès.
failureRedirect chemin /login Où il revient après échec (uniforme, sans détail).
providers dictionnaire par nom {} Fournisseurs activés (config.ts:1031).

Par fournisseur (oauthProviderSchema, config.ts:948) :

<!-- prettier-ignore -->

Option Requis Effet
clientId / clientSecret Identifiants délivrés par l’IdP. Secrets : par env.ts, jamais journalisés.
redirectUri URL de callback exacte (config.ts:958).
issuer OIDC self-hosted Realm Keycloak ; ignoré par les IdP à endpoints fixes.
clientAuthMethod Comment le client s’authentifie au point de jeton (RFC 6749 §2.3). Omis = client_secret_basic, ce que la RFC demande de préférer. Poser client_secret_post quand le serveur l’EXIGE — il le publie dans token_endpoint_auth_methods_supported.
scopes Vide = scopes par défaut du fournisseur.
successRedirect / failureRedirect / defaultRoles Surchargent le global pour ce fournisseur (oauth2.ts:124-131).

Les surcharges par fournisseur permettent la cohabitation : un IdP de recette garde ses redirections et ses rôles pendant qu’un IdP de production pointe ailleurs.

🔐 Sécurité — jetons du fournisseur, révocation, attaques couvertes#

Les jetons du fournisseur ne sont pas conservés#

C’est un choix, et il a des conséquences à connaître. Les jetons obtenus à l’échange vivent dans la portée locale de l’échange (validateAuthorizationCode puis fetchProfile, oauth2.ts:181-182) : ils ne sont ni retournés, ni mis en session, ni persistés. Le profil normalisé qui traverse le système n’en contient aucun (IOAuthUserProvisioner.ts:8-10).

Ce que « révoquer » veut dire ici#

Action Effet sur ton application
Déconnexion (AuthFlow.logout()) Session détruite côté serveur + cookie effacé — immédiat.
Compte local désactivé/verrouillé Rejet à la requête suivante : l’identité est re-résolue à chaque requête.
Autorisation révoquée chez le fournisseur Aucun effet automatique — la session locale reste valide jusqu’à son terme.
allowSignup: false après coup Bloque les nouveaux comptes, pas les liens existants.

La troisième ligne est le piège courant : une fois la session BFF ouverte, ton application ne redemande plus rien à GitHub. Pour couper l’accès, il faut agir localement (désactiver le compte ou détruire les sessions), pas chez le fournisseur.

Attaques couvertes, prouvées par les tests#

Vecteur Défense Preuve
Rejeu du retour (même code, même state) state consommé + session régénérée à la promotion oauth2-attack.test.ts:89 (S5)
state valide présenté au callback d’un autre IdP Fournisseur attendu conservé en session et comparé oauth2-attack.test.ts:115 (S6)
iss falsifié Comparaison stricte à l’émetteur de la politique oauth2Service.test.ts:167
Prise de compte par e-mail collidant un admin Aucune liaison auto : compte séparé, admin intact oauth.attack.test.ts:71 (A1)
Élévation de privilège par re-login Rôles posés à la création, jamais réécrits oauth.attack.test.ts:123 (A2)
Collision d’identifiants entre fournisseurs Clé = provider + providerId oauth.attack.test.ts:155 (A3)
Interception du code PKCE : code_verifier exigé, refus si absent oauthProviders.test.ts:67
Création de compte non voulue Provisioner absent (provisionOAuthUser) → fail-closed oauth2Service.test.ts:52

📜 Normes appliquées#

Domaine Norme Ancrage
Flux Authorization Code RFC 6749 IOAuthProvider.validateAuthorizationCode() (IOAuthProvider.ts:85)
PKCE RFC 7636 usesPkce (IOAuthProvider.ts:58) · oidc.ts:104-111
Sécurité OAuth (BCP 2.1) RFC 9700 OAuth2Service (oauth2.ts:56) · oauth2Schema (config.ts:1001)
Anti-mix-up (iss) RFC 9207 issuerPolicy (IOAuthProvider.ts:61) · oauth2.ts:170-181
Callback en correspondance exacte RFC 9700 §4 redirectUri (config.ts:958)
Claims d’identité OIDC OpenID Connect Core fetchProfile() du helper OIDC (oidc.ts:127-145)
ID token consommé en code flow OIDC Core §3.1.3.7 assertIdTokenClaims() (oidc.ts:132)
Anti-fixation de session OWASP Session Management session.regenerateId() au login (authFlow.ts:388)

Flux exclus par posture 2.1, et donc absents du code : implicit (jeton en fragment d’URL) et password / ROPC (l’application verrait le mot de passe du fournisseur).

📡 Observabilité — Studio#

L’écran de connexion de Studio consomme directement le data plane : il interroge /nodefony/security/api/oauth2/providers (Login.tsx:341) et n’affiche que les fournisseurs opérationnels — zéro bouton mort. Le clic déclenche la redirection vers authorize (Login.tsx:84).

Côté suivi, chaque login réussi produit un événement d’audit auth / login.success via AuthFlow.establishSessionFor() (authFlow.ts:229-236), consultable dans l’écran Audit. La session ouverte apparaît dans l’écran Sessions (IP et agent capturés à l’ouverture) ; le compte provisionné dans l’écran Users, avec ses rôles réels.

ℹ️ Note

L’événement d’audit du login social porte la raison par défaut federated (authFlow.ts:218) : le controller n’affine pas le facteur. Pour distinguer OAuth de WebAuthn dans un filtre d’audit, s’appuyer sur le contexte de la requête plutôt que sur cette seule valeur.

⚠️ Pièges (symptôme → cause → correction)#

Symptôme Cause (dans le code) Correction
404 sur …/oauth2/… Service oauth2 absent (module non chargé / enabled: false) Charger @nodefony/security et activer oauth2
WARNING « inconnu du registre » au boot Nom configuré sans fabrique (oauth2.ts:86-95) registerOAuthProvider() au chargement du module, ou builtin
404 « Unknown provider » sur authorize Le nom n’est pas dans listProviders() (OAuth2Controller.ts:97) Vérifier le nom exact et la présence des secrets
Bouton absent de l’écran de login Secrets manquants → fournisseur non monté (spread conditionnel) Renseigner clientId/clientSecret dans l’env
redirect_uri_mismatch chez le fournisseur redirectUri ≠ URL enregistrée, au caractère près (config.ts:958) Aligner schéma, hôte, port et chemin /…/{provider}/callback
Retour systématique sur failureRedirect state/verifier absents (cookie perdu entre les deux requêtes) Vérifier SameSite/domaine du cookie ; un seul hôte en dev
Callback échoue au deuxième essai state à usage unique, consommé (OAuth2Controller.ts:126-129) Refaire le flux depuis authorize — comportement attendu
OAuth issuer mismatch iss reçu ≠ l’émetteur attendu (oauth2.ts:170-181) Corriger issuer (Keycloak : URL exacte du realm)
Keycloak : erreur dès le premier login issuer absent en config (oauthProviderRegistry.ts:89-93) Renseigner l’URL du realm
« provisioning indisponible » users n’implémente pas la capability (oauth2.ts:224-231) Implémenter provisionOAuthUser() sur le service users
Profil connu refusé allowSignup: false sans lien préexistant (UserService.ts:363) Activer allowSignup ou lier le compte au préalable
Doublon de compte pour un utilisateur existant Aucune liaison auto par e-mail (choix de sécurité) Rattacher explicitement, utilisateur connecté
Rôle attendu absent après re-login Rôles posés à la création seulement (UserService.ts:348) Modifier les rôles en base ; defaultRoles ne réécrit rien
Jeton du fournisseur introuvable côté application IOAuthProfile n’en porte aucun, par choix (IOAuthUserProvisioner.ts:8-10) Le capturer dans son propre fetchProfile() et le stocker soi-même

🧪 Tests & couverture#

Trois familles couvrent le social login — les chiffres exacts vivent dans la carte de l’aperçu (régénérée depuis vitest, jamais figée ici) :

Ce qui manque : aucun banc de charge dédié au social login (le flux est un chemin froid, deux requêtes par connexion), et aucun test contre un IdP réel (impossible à automatiser — le fournisseur de test couvre la branche PKCE).

Couverture : npm run coverage dans @nodefony/security. Campagnes d’attaque : skill nodefony-security-review (mode red/blue-team).

🔗 Pour aller plus loin#