OAuth2 sécurise aujourd’hui plus de 90 % des applications web modernes, des connexions Google aux API d’entreprise. Ce tutoriel vous guide pas à pas pour implémenter OAuth2 avec le flux Authorization Code + PKCE dans une application Express, intégrer Google et GitHub comme fournisseurs, gérer les refresh tokens et sécuriser chaque étape. Durée estimée : 30 minutes. Niveau : intermédiaire.

Pourquoi OAuth2 reste incontournable en 2026

OAuth2 (RFC 6749) définit un cadre d’autorisation déléguée qui permet à une application d’accéder à des ressources au nom d’un utilisateur, sans jamais toucher à son mot de passe. En 2026, OpenID Connect (OIDC), la couche d’identité construite sur OAuth2, propulse l’authentification unique (SSO) de milliards de connexions quotidiennes chez Google, Microsoft, GitHub et des milliers d’entreprises SaaS.

Le projet OAuth 2.1, en cours de finalisation à l’IETF, consolide six ans de correctifs de sécurité en une seule spécification. La principale évolution : PKCE (Proof Key for Code Exchange, RFC 7636) devient obligatoire pour tous les clients publics, y compris les applications web traditionnelles. Cette décision s’impose parce que l’interception de codes d’autorisation reste l’une des attaques OAuth2 les plus documentées en 2025.

Côté réglementaire européen, le RGPD impose une traçabilité des accès aux données personnelles. OAuth2 avec des scopes précis (lecture seule, écriture, suppression) permet d’implémenter le principe du moindre privilège directement dans le protocole d’authentification. Une application qui demande uniquement les scopes nécessaires réduit la surface d’exposition en cas de compromission de token.

Comparé à l’authentification basique (identifiant/mot de passe), OAuth2 présente trois avantages décisifs. Premièrement, les tokens d’accès ont une durée de vie courte (15 à 60 minutes), ce qui limite la fenêtre d’exploitation après une fuite. Deuxièmement, les refresh tokens permettent de renouveler la session sans demander à l’utilisateur de se ré-authentifier. Troisièmement, la révocation des tokens est instantanée côté serveur d’autorisation, sans nécessiter de modification côté client.

Pour Node.js, l’écosystème s’est structuré autour de trois bibliothèques complémentaires. openid-client gère le protocole OIDC côté client (découverte, échange de tokens, PKCE). passport orchestre les stratégies d’authentification dans Express. express-oauth2-jwt-bearer valide les tokens d’accès entrants sur les API resource servers. Ce tutoriel utilise principalement openid-client pour une implémentation directe du protocole, sans couche d’abstraction supplémentaire.

Un point souvent négligé : OAuth2 ne remplace pas la protection CSRF sur vos formulaires applicatifs. Le paramètre state protège le flux OAuth2 lui-même, mais vos autres routes POST nécessitent toujours une protection CSRF dédiée, couverte dans l’article CSRF Protection en Node.js.

Prérequis et versions

Avant de commencer, vérifiez que votre environnement correspond aux versions suivantes. Les incompatibilités de version sont la première cause d’échec lors de l’implémentation OAuth2 en Node.js.

OutilVersion minimaleVérification
Node.js20.x LTSnode --version
npm10.xnpm --version
Express4.xnpm list express
openid-client5.xnpm list openid-client
express-session1.xnpm list express-session
helmet7.xnpm list helmet
Redis7.x (production)redis-server --version

Vous aurez également besoin d’un compte Google Cloud (gratuit) pour créer une application OAuth2 dans la Google API Console, et d’un compte GitHub pour l’intégration du fournisseur GitHub. Les deux sont accessibles gratuitement en développement.

Connaissances supposées : bases de JavaScript asynchrone (async/await), HTTP (requêtes, cookies, en-têtes), et une compréhension élémentaire des JWT. Pour approfondir les JWT avant ce tutoriel, consultez l’article JWT Authentication en Node.js.

Les 4 flux OAuth2 : tableau comparatif

OAuth2 définit plusieurs flux (grant types) adaptés à différents contextes. Choisir le mauvais flux est une erreur de conception qui crée des vulnérabilités structurelles. Voici les quatre flux principaux et quand les utiliser :

Flux OAuth2Cas d’usageClient secretPKCE requisRefresh token
Authorization Code + PKCEApplications web serveur, SPAs, mobileOptionnelObligatoire (OAuth 2.1)Oui
Authorization Code (legacy)Applications serveur confidentiellesObligatoireRecommandéOui
Client CredentialsÉchanges machine à machine (M2M, API)ObligatoireNon applicableNon
Device AuthorizationTV connectée, CLI, appareils sans navigateurOptionnelNon (flux distinct)Oui

Ce tutoriel implémente le flux Authorization Code + PKCE, qui convient à la majorité des applications web Node.js. Le flux Client Credentials est également abordé à l’étape 11 pour les cas d’usage machine à machine.

PKCE fonctionne en deux temps. Avant la redirection vers le fournisseur d’identité, le client génère un code_verifier aléatoire de 43 à 128 caractères, puis calcule un code_challenge en appliquant SHA-256 sur ce verifier (méthode S256). Lors de l’échange du code d’autorisation contre un token, le client renvoie le code_verifier original. Le serveur d’autorisation recalcule SHA-256(code_verifier) et vérifie la correspondance avec le challenge initial. Un attaquant qui intercepte le code d’autorisation ne peut pas l’utiliser sans posséder le code_verifier qu’il n’a jamais vu. C’est la garantie fondamentale de PKCE.

Étapes 1-2 : Initialiser le projet et les dépendances

Étape 1 : Créer la structure du projet

Créez un nouveau répertoire et initialisez le projet Node.js. La structure ci-dessous sépare les routes, les middlewares et la configuration pour faciliter la maintenance et les tests unitaires.

mkdir oauth2-nodejs-tutorial
cd oauth2-nodejs-tutorial
npm init -y

# Créer la structure de répertoires
mkdir -p src/{routes,middleware,config,services}
touch src/app.js src/config/oidc.js src/routes/auth.js
touch src/middleware/auth.js src/services/tokenService.js
touch .env .env.example .gitignore

# Ajouter .env au .gitignore immédiatement
echo ".env" >> .gitignore
echo "node_modules/" >> .gitignore

Étape 2 : Installer les dépendances

Installez les paquets nécessaires. openid-client gère le protocole OIDC complet avec PKCE. express-session stocke le code_verifier et les tokens côté serveur. helmet ajoute automatiquement les en-têtes de sécurité HTTP (Content-Security-Policy, HSTS, X-Frame-Options).

# Dépendances de production
npm install express openid-client express-session dotenv helmet

# Pour la production avec Redis (sessions persistantes)
npm install connect-redis redis

# Dépendances de développement
npm install --save-dev nodemon

Vérifiez l’installation avec npm list --depth=0. Vous devriez voir express, openid-client, express-session, dotenv et helmet dans la liste des dépendances directes. Ajoutez le script de démarrage dans package.json :

{
  "scripts": {
    "start": "node src/app.js",
    "dev": "nodemon src/app.js"
  },
  "engines": {
    "node": ">=20.0.0"
  }
}

Étapes 3-4 : Variables d’environnement et enregistrement Google

Étape 3 : Configurer les variables d’environnement

Les secrets OAuth2 ne doivent jamais être écrits en dur dans le code source. Créez le fichier .env avec les variables suivantes. Générez le SESSION_SECRET avec Node.js pour obtenir une valeur cryptographiquement sûre de 64 octets.

# Générer un secret de session sécurisé
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
# .env
NODE_ENV=development
PORT=3000

# Session (remplacez par la valeur générée ci-dessus)
SESSION_SECRET=votre_secret_session_64_octets_aleatoire

# Google OAuth2
GOOGLE_CLIENT_ID=votre_client_id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-votre_client_secret
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/google/callback

# GitHub OAuth2
GITHUB_CLIENT_ID=votre_github_client_id
GITHUB_CLIENT_SECRET=votre_github_client_secret
GITHUB_REDIRECT_URI=http://localhost:3000/auth/github/callback

# URL de base de l'application
APP_BASE_URL=http://localhost:3000

Étape 4 : Enregistrer l’application dans Google Cloud Console

Suivez ces 6 étapes sur console.cloud.google.com :

  1. Créez un nouveau projet ou sélectionnez un projet existant dans le menu déroulant en haut
  2. Naviguez vers APIs & Services > OAuth consent screen et configurez le consentement en mode “External”
  3. Allez dans APIs & Services > Credentials et cliquez sur “Create Credentials > OAuth client ID”
  4. Sélectionnez “Web application” comme type d’application
  5. Ajoutez http://localhost:3000/auth/google/callback dans “Authorized redirect URIs”
  6. Copiez le Client ID et le Client Secret dans votre fichier .env

Pour GitHub, rendez-vous dans Settings > Developer settings > OAuth Apps > New OAuth App. L’URL de rappel sera http://localhost:3000/auth/github/callback. En production, toutes les redirect URIs doivent utiliser HTTPS. Consultez l’article Let’s Encrypt + Nginx : HTTPS en 12 étapes pour la mise en place.

Étapes 5-6 : Sessions Express et client OIDC

Étape 5 : Configurer Express avec les sessions et Helmet

Les sessions HTTP stockent le code_verifier PKCE et les tokens entre les requêtes. Le cookie de session doit impérativement être httpOnly pour empêcher l’accès depuis JavaScript (protection XSS), Secure en production (HTTPS uniquement), et SameSite: lax pour une protection CSRF partielle.

// src/app.js
require('dotenv').config();
const express = require('express');
const session = require('express-session');
const helmet = require('helmet');
const authRouter = require('./routes/auth');
const { requireAuth, checkTokenExpiry } = require('./middleware/auth');

const app = express();

// En-têtes de sécurité HTTP avec Helmet
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", 'https://lh3.googleusercontent.com', 'https://avatars.githubusercontent.com'],
      connectSrc: ["'self'"],
    },
  },
  hsts: {
    maxAge: 31536000,
    includeSubDomains: true,
  },
}));

app.use(express.json());
app.use(express.urlencoded({ extended: false }));

// Configuration des sessions
app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  name: 'sid', // Renommer le cookie pour éviter le fingerprinting
  cookie: {
    secure: process.env.NODE_ENV === 'production',
    httpOnly: true,
    sameSite: 'lax',
    maxAge: 24 * 60 * 60 * 1000, // 24 heures
  },
}));

// Middleware de renouvellement automatique des tokens
app.use(checkTokenExpiry);

app.use('/auth', authRouter);

// Route protégée d'exemple
app.get('/dashboard', requireAuth, (req, res) => {
  res.json({
    user: req.session.user,
    provider: req.session.user?.provider,
  });
});

app.get('/', (req, res) => {
  const user = req.session.user;
  res.send(`
    <h1>OAuth2 Demo Node.js</h1>
    ${user
      ? `<p>Connecté : ${user.name} (${user.email})</p>
         <a href="/dashboard">Tableau de bord</a> |
         <a href="/auth/logout">Déconnexion</a>`
      : `<a href="/auth/google">Connexion avec Google</a> |
         <a href="/auth/github">Connexion avec GitHub</a>`
    }
  `);
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Serveur OAuth2 démarré sur http://localhost:${PORT}`);
});

Étape 6 : Initialiser le client OIDC avec découverte automatique

La découverte OIDC (RFC 8414) récupère automatiquement la configuration du fournisseur depuis l’URL /.well-known/openid-configuration. Pour Google, cette URL est https://accounts.google.com/.well-known/openid-configuration et expose tous les endpoints (autorisation, token, userinfo, révocation) ainsi que les algorithmes supportés. Le pattern singleton garantit une seule découverte au démarrage.

// src/config/oidc.js
const { Issuer } = require('openid-client');

let googleClient = null;

async function getGoogleClient() {
  if (googleClient) return googleClient;

  // Découverte automatique de la configuration Google OIDC
  const googleIssuer = await Issuer.discover('https://accounts.google.com');

  console.log('OIDC découverte réussie :', googleIssuer.metadata.issuer);

  googleClient = new googleIssuer.Client({
    client_id: process.env.GOOGLE_CLIENT_ID,
    client_secret: process.env.GOOGLE_CLIENT_SECRET,
    redirect_uris: [process.env.GOOGLE_REDIRECT_URI],
    response_types: ['code'],
  });

  return googleClient;
}

// Pré-charger la configuration au démarrage (optionnel mais recommandé)
async function initializeOIDC() {
  try {
    await getGoogleClient();
    console.log('Client OIDC initialisé avec succès');
  } catch (err) {
    console.error('Échec initialisation OIDC:', err.message);
    process.exit(1); // Arrêter si la découverte échoue
  }
}

module.exports = { getGoogleClient, initializeOIDC };

Étapes 7-8 : Routes d’authentification et middleware

Étape 7 : Créer les routes OAuth2 avec PKCE complet

Le fichier de routes gère trois endpoints : le démarrage du flux (/auth/google), la réception du callback (/auth/google/callback) et la déconnexion (/auth/logout). Chaque étape du flux inclut une validation stricte pour prévenir les attaques par manipulation de paramètres.

// src/routes/auth.js
const express = require('express');
const { generators } = require('openid-client');
const { getGoogleClient } = require('../config/oidc');

const router = express.Router();

// --- GOOGLE OAUTH2 ---

// Étape 7a : Démarrer le flux Authorization Code + PKCE
router.get('/google', async (req, res) => {
  try {
    const client = await getGoogleClient();

    // Générer les paramètres PKCE
    const code_verifier = generators.codeVerifier(); // 128 chars, URL-safe base64
    const code_challenge = generators.codeChallenge(code_verifier); // SHA-256 hash
    const state = generators.state(); // Nonce aléatoire anti-CSRF

    // Stocker en session (côté serveur, jamais transmis au client)
    req.session.code_verifier = code_verifier;
    req.session.state = state;
    req.session.returnTo = req.session.returnTo || '/dashboard';

    // Construire l'URL d'autorisation Google
    const authUrl = client.authorizationUrl({
      scope: 'openid email profile',
      code_challenge,
      code_challenge_method: 'S256',
      state,
      access_type: 'offline', // Demander un refresh_token
      prompt: 'consent',      // Forcer l'affichage du consentement (pour refresh_token)
    });

    res.redirect(authUrl);
  } catch (err) {
    console.error('Erreur démarrage OAuth2 Google:', err.message);
    res.redirect('/?error=oauth_init_failed');
  }
});

// Étape 7b : Traiter le callback Google
router.get('/google/callback', async (req, res) => {
  try {
    const client = await getGoogleClient();

    // Récupérer et supprimer les valeurs PKCE de la session
    const code_verifier = req.session.code_verifier;
    const state = req.session.state;
    const returnTo = req.session.returnTo || '/dashboard';

    delete req.session.code_verifier;
    delete req.session.state;

    if (!code_verifier || !state) {
      return res.redirect('/?error=session_expired');
    }

    // Valider le callback et échanger le code d'autorisation contre des tokens
    const params = client.callbackParams(req);
    const tokenSet = await client.callback(
      process.env.GOOGLE_REDIRECT_URI,
      params,
      {
        code_verifier, // Validation PKCE
        state,         // Validation anti-CSRF
      }
    );

    // Récupérer les informations utilisateur depuis le userinfo endpoint OIDC
    const userInfo = await client.userinfo(tokenSet.access_token);

    // Régénérer la session pour prévenir la fixation de session
    req.session.regenerate((err) => {
      if (err) return res.redirect('/?error=session_error');

      req.session.user = {
        sub: userInfo.sub,
        email: userInfo.email,
        name: userInfo.name,
        picture: userInfo.picture,
        provider: 'google',
      };

      req.session.tokens = {
        access_token: tokenSet.access_token,
        refresh_token: tokenSet.refresh_token,
        expires_at: tokenSet.expires_at,
        id_token: tokenSet.id_token,
      };

      res.redirect(returnTo);
    });

  } catch (err) {
    console.error('Erreur callback OAuth2 Google:', err.message);
    res.redirect('/?error=auth_failed');
  }
});

// Étape 7c : Déconnexion complète (locale + Google)
router.get('/logout', async (req, res) => {
  const id_token = req.session.tokens?.id_token;

  req.session.destroy((err) => {
    if (err) console.error('Erreur destruction session:', err);
  });

  try {
    if (id_token) {
      const client = await getGoogleClient();
      const endSessionUrl = client.endSessionUrl({
        id_token_hint: id_token,
        post_logout_redirect_uri: process.env.APP_BASE_URL,
      });
      return res.redirect(endSessionUrl);
    }
  } catch (err) {
    // En cas d'erreur OIDC, déconnecter localement quand même
  }

  res.redirect('/');
});

module.exports = router;

Étape 8 : Créer le middleware d’authentification

Le middleware requireAuth vérifie la présence d’un utilisateur en session et sauvegarde l’URL demandée pour une redirection post-login. Le middleware checkTokenExpiry renouvelle automatiquement les access tokens qui arrivent à expiration.

// src/middleware/auth.js
const { getGoogleClient } = require('../config/oidc');

function requireAuth(req, res, next) {
  if (!req.session?.user) {
    req.session.returnTo = req.originalUrl;
    return res.redirect('/auth/google');
  }
  next();
}

async function checkTokenExpiry(req, res, next) {
  if (!req.session?.tokens) return next();

  const { expires_at, refresh_token, access_token } = req.session.tokens;
  const now = Math.floor(Date.now() / 1000);

  // Renouveler si le token expire dans moins de 5 minutes
  const shouldRefresh = expires_at
    ? (expires_at - now < 300)
    : false;

  if (shouldRefresh && refresh_token && req.session.user?.provider === 'google') {
    try {
      const client = await getGoogleClient();
      const newTokenSet = await client.refresh(refresh_token);

      req.session.tokens = {
        access_token: newTokenSet.access_token,
        // Certains fournisseurs pratiquent la rotation : toujours conserver le nouveau
        refresh_token: newTokenSet.refresh_token || refresh_token,
        expires_at: newTokenSet.expires_at,
        id_token: newTokenSet.id_token || req.session.tokens.id_token,
      };
    } catch (err) {
      console.error('Échec renouvellement token:', err.message);
      req.session.destroy();
      return res.redirect('/auth/google');
    }
  }

  next();
}

module.exports = { requireAuth, checkTokenExpiry };

Étape 9 : Flux PKCE pas à pas

Voici le déroulement complet du flux Authorization Code + PKCE tel qu'il s'exécute dans le code ci-dessus :

  1. Génération du code_verifier : l'application génère une chaîne aléatoire cryptographiquement sûre de 128 caractères via generators.codeVerifier(). Cette valeur reste secrète, stockée en session côté serveur.
  2. Calcul du code_challenge : l'application calcule BASE64URL(SHA-256(code_verifier)) via generators.codeChallenge(code_verifier). Ce hash est public et envoyé au serveur d'autorisation.
  3. Redirection vers Google : l'utilisateur est envoyé vers accounts.google.com/o/oauth2/auth avec le code_challenge, la méthode S256, le state anti-CSRF, et les scopes demandés.
  4. Authentification et consentement : l'utilisateur se connecte à Google et accorde les permissions. Google stocke le code_challenge associé à cette session.
  5. Code d'autorisation renvoyé : Google redirige vers /auth/google/callback avec un code d'autorisation à usage unique, valable 10 minutes maximum.
  6. Échange token avec PKCE : l'application envoie le code d'autorisation ET le code_verifier original à l'endpoint token de Google.
  7. Vérification et émission des tokens : Google recalcule SHA-256(code_verifier) et compare avec le code_challenge stocké. En cas de correspondance, Google émet l'access_token (validité 1 heure), le refresh_token et l'id_token.

Un attaquant qui intercepte le code d'autorisation à l'étape 5 (via un malware, une mauvaise configuration HTTPS, ou un log de serveur) est bloqué à l'étape 6 : sans le code_verifier, le code intercepté est inutilisable. Pour approfondir la gestion sécurisée des clés et certificats utilisés dans les protocoles TLS sous-jacents, consultez l'article OpenSSL : clés et certificats en 12 étapes.

Étapes 10-11 : Refresh tokens et intégration GitHub

Étape 10 : Service de gestion des refresh tokens

Le middleware checkTokenExpiry gère le renouvellement automatique. Pour les cas plus complexes (renouvellement forcé, vérification de validité sans requête HTTP), créez un service dédié :

// src/services/tokenService.js
const { getGoogleClient } = require('../config/oidc');

function isTokenExpired(tokens) {
  if (!tokens?.expires_at) return false; // Sans expiration connue, supposer valide
  const now = Math.floor(Date.now() / 1000);
  return tokens.expires_at <= now + 60; // Marge de 60 secondes
}

async function refreshGoogleToken(session) {
  if (!session.tokens?.refresh_token) {
    throw new Error('Aucun refresh_token disponible. Reconnexion requise.');
  }

  const client = await getGoogleClient();

  const newTokenSet = await client.refresh(session.tokens.refresh_token);

  // IMPORTANT : Google pratique la rotation des refresh_tokens sur certains événements
  // (changement de mot de passe, révocation). Toujours conserver le nouveau refresh_token.
  session.tokens = {
    access_token: newTokenSet.access_token,
    refresh_token: newTokenSet.refresh_token || session.tokens.refresh_token,
    expires_at: newTokenSet.expires_at,
    id_token: newTokenSet.id_token || session.tokens.id_token,
  };

  return session.tokens;
}

module.exports = { isTokenExpired, refreshGoogleToken };

Étape 11 : Intégration GitHub OAuth2

GitHub utilise OAuth2 standard sans couche OIDC. Différences clés : pas de découverte automatique, pas d'id_token JWT, et les informations utilisateur se récupèrent via l'API REST api.github.com/user. L'utilisation de fetch natif (Node.js 18+) évite une dépendance externe.

// Ajout dans src/routes/auth.js

const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize';
const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';

// Démarrer le flux GitHub
router.get('/github', (req, res) => {
  const state = generators.state();
  req.session.github_state = state;

  const params = new URLSearchParams({
    client_id: process.env.GITHUB_CLIENT_ID,
    redirect_uri: process.env.GITHUB_REDIRECT_URI,
    scope: 'read:user user:email',
    state,
  });

  res.redirect(`${GITHUB_AUTH_URL}?${params}`);
});

// Traiter le callback GitHub
router.get('/github/callback', async (req, res) => {
  const { code, state, error } = req.query;

  if (error) return res.redirect(`/?error=${encodeURIComponent(error)}`);

  // Validation du state anti-CSRF
  if (!state || state !== req.session.github_state) {
    return res.redirect('/?error=state_mismatch');
  }
  delete req.session.github_state;

  try {
    // Échanger le code contre un access_token GitHub
    const tokenResponse = await fetch(GITHUB_TOKEN_URL, {
      method: 'POST',
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        client_id: process.env.GITHUB_CLIENT_ID,
        client_secret: process.env.GITHUB_CLIENT_SECRET,
        code,
        redirect_uri: process.env.GITHUB_REDIRECT_URI,
      }),
    });

    const tokenData = await tokenResponse.json();
    if (tokenData.error) {
      throw new Error(`GitHub OAuth error: ${tokenData.error_description}`);
    }

    // Récupérer le profil utilisateur via l'API GitHub
    const userResponse = await fetch('https://api.github.com/user', {
      headers: {
        'Authorization': `Bearer ${tokenData.access_token}`,
        'Accept': 'application/vnd.github.v3+json',
        'User-Agent': 'OAuth2-Node-App',
      },
    });

    if (!userResponse.ok) {
      throw new Error(`GitHub API error: ${userResponse.status}`);
    }

    const githubUser = await userResponse.json();

    req.session.regenerate((err) => {
      if (err) return res.redirect('/?error=session_error');

      req.session.user = {
        sub: String(githubUser.id),
        email: githubUser.email,
        name: githubUser.name || githubUser.login,
        picture: githubUser.avatar_url,
        provider: 'github',
      };
      req.session.tokens = {
        access_token: tokenData.access_token,
        expires_at: null, // GitHub ne fournit pas d'expiration par défaut
      };

      res.redirect('/dashboard');
    });

  } catch (err) {
    console.error('Erreur GitHub OAuth:', err.message);
    res.redirect('/?error=github_auth_failed');
  }
});

Étape 12 : Tester et valider la sécurité

Démarrez l'application avec npm run dev et testez le flux complet. La console affiche les messages de découverte OIDC et les logs d'authentification. Ouvrez http://localhost:3000, cliquez sur "Connexion avec Google" et suivez le flux :

  1. La page Google s'affiche avec la demande de permissions "email" et "profil"
  2. Après approbation, vous êtes redirigé vers /dashboard avec vos informations utilisateur
  3. Ouvrez les DevTools navigateur (Onglet Application > Cookies) et vérifiez que le cookie sid est marqué HttpOnly
  4. Testez la déconnexion et vérifiez que /dashboard redirige vers Google

Voici la liste de contrôle de sécurité à valider avant tout déploiement en production :

Point de contrôlePrioritéAction corrective
Cookie HttpOnly activéCritiqueVérifier httpOnly: true dans la config session
Cookie Secure en productionCritiqueDéployer derrière HTTPS, activer secure: true
PKCE méthode S256CritiqueVérifier code_challenge_method: 'S256'
Paramètre state validéCritiqueComparer state reçu avec state en session
Session régénérée après loginÉlevéeAppeler req.session.regenerate() après auth
Secrets hors du code sourceCritiqueVariables d'environnement + .gitignore
En-têtes Helmet configurésÉlevéeCSP, HSTS, X-Frame-Options activés
Store de session RedisCritique (production)Remplacer MemoryStore par connect-redis

Pour configurer Redis comme store de sessions persistant, l'article Node.js Session Management couvre l'intégration complète avec connect-redis, la rotation des secrets et les stratégies d'expiration.

Les 7 pièges courants avec OAuth2 en Node.js

Piège 1 : Stocker les tokens dans localStorage

C'est l'erreur la plus répandue dans les tutoriels en ligne. localStorage est accessible par tout JavaScript exécuté sur la page, ce qui rend les tokens vulnérables aux attaques XSS. Un seul script malveillant injecté (via une dépendance npm compromise ou une injection XSS) peut exfiltrer tous les tokens en quelques millisecondes. Stockez toujours les tokens dans des cookies httpOnly ou en session côté serveur. Les SPAs qui consomment une API peuvent utiliser des cookies SameSite=Strict avec un proxy backend.

Piège 2 : Omettre PKCE parce que l'application a un client_secret

Beaucoup d'implémentations legacy utilisent le flux Authorization Code sans PKCE au motif que leur application côté serveur possède un client_secret. C'est insuffisant : si le callback URL est compromis ou si un malware surveille les redirections du navigateur, le code d'autorisation peut être intercepté et échangé depuis un autre serveur. PKCE est obligatoire dans OAuth 2.1 et doit être traité comme tel, même pour les clients confidentiels.

Piège 3 : Ne pas valider le paramètre state

Omettre la validation du state expose l'application aux attaques Login CSRF : un attaquant initie un flux OAuth2 et manipule la victime pour terminer l'authentification avec le compte de l'attaquant (ce qui lie le compte de la victime aux credentials de l'attaquant). Générez toujours un state cryptographiquement aléatoire, stockez-le en session avant la redirection, et comparez-le strictement à la valeur reçue dans le callback.

Piège 4 : Tokens d'accès de longue durée

Configurer des access tokens valables 24 heures annule l'un des principaux avantages d'OAuth2. Les bonnes pratiques 2025-2026 recommandent des access tokens valables 15 à 60 minutes, avec des refresh tokens pour le renouvellement silencieux. Un token volé devient inutile très rapidement, limitant les dommages en cas de compromission. Google émet des access tokens d'exactement 3 600 secondes (1 heure) par défaut.

Piège 5 : Session en mémoire en production

Le store de session par défaut d'express-session est MemoryStore, explicitement documenté comme non adapté à la production. Il fuit de la mémoire avec le nombre de connexions simultanées, perd toutes les sessions lors d'un redémarrage serveur, et empêche le scaling horizontal (plusieurs instances Node.js). En production, utilisez Redis avec connect-redis pour des sessions persistantes, partagées et scalables.

Piège 6 : redirect_uri validée par préfixe plutôt que par correspondance exacte

Certains développeurs enregistrent https://monapp.com/ et acceptent n'importe quelle URL débutant par ce préfixe. Le serveur d'autorisation doit comparer l'URI de redirection par correspondance exacte de chaîne. Si votre fournisseur tolère une validation laxiste, un attaquant peut rediriger le code d'autorisation vers https://monapp.com.evil.com/ ou exploiter des open redirectors sur votre domaine. Enregistrez toujours les URIs complètes et précises.

Piège 7 : Ne pas révoquer la session côté fournisseur à la déconnexion

Détruire uniquement la session Express locale laisse la session active côté fournisseur d'identité. Si l'utilisateur est sur un ordinateur partagé, une autre personne peut rouvrir le navigateur et se connecter directement via le SSO Google, sans passer par votre application. Utilisez toujours l'end session endpoint OIDC (couvert à l'étape 7c) pour invalider la session côté fournisseur. Pour les fournisseurs sans OIDC, appelez l'endpoint de révocation OAuth2 (RFC 7009) si disponible.

Dépannage : 8 erreurs OAuth2 fréquentes

Erreur 1 : redirect_uri_mismatch

L'URI de redirection dans la requête ne correspond pas exactement à celle enregistrée. Vérifiez : présence ou absence du slash final (/callback vs /callback/), protocole (http vs https), numéro de port (:3000), et casse exacte. Copiez-collez l'URI directement depuis la console du fournisseur vers votre .env pour éviter toute erreur.

Erreur 2 : invalid_grant

Le code d'autorisation a déjà été utilisé (il est à usage unique), a expiré (10 minutes maximum chez Google), ou le code_verifier ne correspond pas au code_challenge envoyé. Ne tentez jamais de rejouer un code. Chaque clic sur "Connexion avec Google" génère un nouveau couple code_verifier/code_challenge.

Erreur 3 : invalid_client

Le client_id ou le client_secret est incorrect. Vérifiez que les variables d'environnement sont bien chargées en ajoutant temporairement console.log('CLIENT_ID:', process.env.GOOGLE_CLIENT_ID?.substring(0, 10)) au démarrage. Assurez-vous que le fichier .env se trouve à la racine du projet et que require('dotenv').config() est la première instruction du fichier principal.

Erreur 4 : Session perdue entre les requêtes (code_verifier manquant)

Le code_verifier stocké en session lors de /auth/google est absent lors du callback. Causes fréquentes : cookie de session non transmis (vérifier SameSite=Lax et domaine correct), deux instances de l'application avec des secrets de session différents, ou rechargement de page pendant le flux. En développement, utilisez exclusivement localhost (pas 127.0.0.1) pour la cohérence des cookies.

Erreur 5 : checks.state argument is missing

openid-client exige que le state soit passé à client.callback() pour validation. Vérifiez que req.session.state est défini avant la redirection et qu'il est inclus dans l'objet de vérification. Cette erreur apparaît fréquemment quand la session expire entre la requête initiale et le callback (plus de quelques minutes).

Erreur 6 : Token expiré sur toutes les requêtes protégées

Le middleware checkTokenExpiry doit être placé globalement avec app.use(checkTokenExpiry), après la configuration des sessions mais avant les routes protégées. S'il est seulement appliqué sur certaines routes, d'autres routes continueront à utiliser des tokens expirés jusqu'à ce qu'une erreur 401 déclenche une reconnexion.

Erreur 7 : Erreurs CORS sur l'API Node.js

Les requêtes depuis un SPA frontend vers votre API Node.js déclenchent des erreurs CORS si les en-têtes Access-Control-Allow-Origin ne sont pas configurés. Installez cors et configurez les origines autorisées explicitement (évitez * avec des credentials). Pour les routes protégées par tokens Bearer OAuth2, exposez l'en-tête Authorization dans la configuration CORS : allowedHeaders: ['Authorization', 'Content-Type'].

Erreur 8 : Timeout lors de la découverte OIDC au démarrage

Issuer.discover() effectue une requête réseau au démarrage. Dans les environnements Docker ou Kubernetes avec restrictions réseau sortant, cette requête peut échouer ou timeout. Solution : encapsulez l'appel dans une boucle de retry avec backoff exponentiel (3 tentatives, délai doublé à chaque fois), et retardez l'écoute du port HTTP avec app.listen() jusqu'à la réussite de la découverte OIDC. Ajoutez également un timeout de 10 secondes sur chaque tentative.

Pour renforcer la protection de vos endpoints d'authentification contre les attaques de force brute et le credential stuffing, l'article HMAC-SHA256 en Node.js couvre la signature des webhooks et la détection des requêtes forgées.

Conseils avancés

Support multi-fournisseurs avec Passport.js

Pour une application gérant plusieurs fournisseurs OAuth2 (Google, GitHub, Microsoft, LinkedIn), Passport.js unifie l'orchestration. Chaque fournisseur s'enregistre via passport.use() avec une stratégie dédiée. La sérialisation stocke uniquement le couple (provider, user.id) en session, sans tokens, ce qui minimise la taille des cookies et améliore la sécurité. passport.deserializeUser recharge l'utilisateur depuis la base de données à chaque requête authentifiée. Combinez Passport avec openid-client pour garder la conformité PKCE : les stratégies Passport officielles pour OIDC (comme passport-openidconnect) s'appuient sur openid-client en interne depuis 2024.

Resource server : valider les tokens Bearer entrants

Quand votre API Node.js joue le rôle de resource server (validation des tokens émis par Auth0, Okta ou un serveur d'autorisation interne), utilisez express-oauth2-jwt-bearer. Ce middleware vérifie la signature JWT via les JWKS publics du fournisseur, l'émetteur (iss), l'audience (aud), l'expiration (exp) et les scopes requis en une seule ligne. Retournez 401 pour les tokens manquants ou invalides, et 403 pour les tokens valides mais sans les scopes requis. Cette distinction est fondamentale : ne mélangez jamais ces deux codes d'erreur, sous peine de masquer des problèmes d'autorisation.

Sessions Redis en production

Remplacez MemoryStore par Redis avec connect-redis en 4 lignes :

const { createClient } = require('redis');
const RedisStore = require('connect-redis').default;

const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();

app.use(session({
  store: new RedisStore({ client: redisClient, prefix: 'oauth2sess:' }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: { secure: true, httpOnly: true, sameSite: 'lax', maxAge: 86400000 },
}));

Redis permet également d'implémenter une liste noire de sessions révoquées (logout global de tous les appareils), de partager les sessions entre plusieurs instances Node.js derrière un load balancer, et de configurer une expiration TTL automatique des sessions inactives. La clé prefix oauth2sess: facilite l'administration et la surveillance des sessions dans l'interface Redis.

FAQ sur OAuth2 en Node.js

Quelle est la différence entre OAuth2 et OpenID Connect ?

OAuth2 est un protocole d'autorisation : il permet à une application d'accéder à des ressources au nom d'un utilisateur. OpenID Connect (OIDC) est une couche d'authentification construite au-dessus d'OAuth2 : il ajoute un id_token JWT qui contient des informations d'identité vérifiables (sub, email, name). Utilisez OIDC quand vous avez besoin de savoir qui est l'utilisateur. Utilisez OAuth2 seul quand vous avez besoin d'accéder à une API tierce au nom de l'utilisateur (exemple : lire ses emails Gmail).

Passport.js ou openid-client directement ?

Pour une application avec un seul fournisseur OIDC, openid-client directement offre plus de contrôle sur le protocole et une implémentation plus légère (aucune couche d'abstraction). Pour une application avec plusieurs stratégies d'authentification (local + Google + GitHub + SAML), Passport.js unifie la gestion des sessions et des stratégies. Les deux sont complémentaires : openid-client pour le protocole, Passport pour l'orchestration.

Pourquoi le flux Implicit est-il déprécié ?

Le flux Implicit émettait les tokens directement dans le fragment URL de la redirection (#access_token=...&token_type=bearer), les exposant à l'historique du navigateur, aux logs serveur et aux en-têtes Referer. OAuth 2.1 supprime ce flux au profit du flux Authorization Code + PKCE, qui offre les mêmes avantages pour les SPAs sans exposition des tokens dans l'URL.

OAuth2 est-il conforme RGPD ?

OAuth2 est un protocole technique, pas une garantie de conformité RGPD par lui-même. Cependant, il facilite la conformité par ses scopes granulaires (principe du moindre privilège), la traçabilité des accès via les logs du serveur d'autorisation, et la révocation immédiate des accès. Documentez les scopes demandés et leur finalité dans votre politique de confidentialité, conformément à l'article 13 du RGPD.

Comment gérer plusieurs sessions simultanées par utilisateur ?

Associez chaque session à un device_id stocké dans un cookie distinct non-httpOnly. Côté serveur, maintenez une table Redis des sessions actives par user_id (clé : user:sessions:{user_id}, valeur : liste des session IDs actifs). Cette architecture permet la révocation de sessions individuelles (déconnexion d'un seul appareil) ou la déconnexion globale, une fonctionnalité attendue dans les applications professionnelles.

Comment tester OAuth2 sans fournisseur externe ?

Utilisez node-oidc-provider (npm), qui déploie un serveur OIDC complet en une vingtaine de lignes Node.js. Ou Ory Hydra via Docker pour une implémentation OAuth2/OIDC en production. Ces solutions permettent d'écrire des tests d'intégration couvrant le flux complet sans dépendre de fournisseurs externes, éliminant les tests flaky dus aux timeouts réseau et aux limites de rate limiting des APIs Google ou GitHub.

Comment sécuriser les refresh tokens ?

Les refresh tokens ont une durée de vie longue (jours à mois) et permettent d'obtenir de nouveaux access tokens sans interaction utilisateur. Stockez-les exclusivement côté serveur (session Redis ou base de données chiffrée), jamais dans le navigateur. Implémentez la rotation des refresh tokens : à chaque utilisation, le serveur d'autorisation émet un nouveau refresh token et invalide l'ancien. Google active la rotation automatiquement dans certaines circonstances (changement de mot de passe, révocation de l'application).

Liens connexes

Articles liés sur shattered.io

Ressources officielles : spécification OAuth2 (oauth.net), RFC 6749 IETF, spécification OpenID Connect Core, documentation Passport.js, OWASP Top 10.