Aller au contenu principal
AIVO Logo
Cybersécurité & Résilience

Architecture Pure BFF & Token Handler : Le Guide Ultime de Sécurité OIDC / OAuth 2.0 pour SPAs

Nicolas BAUD

Nicolas BAUD

Fractional CTO & Architecte IA/SI • Conseil AMOA

Publié le
Temps de lecture 23 MIN
Architecture Pure BFF & Token Handler : Le Guide Ultime de Sécurité OIDC / OAuth 2.0 pour SPAs

1. Introduction : Pourquoi le stockage de tokens dans le navigateur est mort

Pendant des années, le modèle standard d’authentification pour les Single-Page Applications (SPA) consistait à récupérer un access_token (JWT) et un refresh_token depuis un serveur d’authentification (Keycloak, Auth0, Okta), puis à les stocker dans le navigateur du client :

  • En localStorage ou sessionStorage : Vulnérable à 100 % à toute faille XSS (Cross-Site Scripting). La moindre bibliothèque JavaScript tierce malveillante (supply chain attack) ou injection HTML peut lire le stockage local et exfiltrer le jeton de rafraîchissement, donnant un accès illimité au système d’information.
  • Dans un cookie JavaScript non sécurisé : Mêmes vulnérabilités d’exfiltration.

Face à ces menaces, le groupe de travail IETF OAuth (draft-ietf-oauth-browser-based-apps) et l’OWASP recommandent formellement le pattern Backend-For-Frontend (BFF) avec le pattern Token Handler.

Le principe d’un Pure BFF

  1. Zéro jeton dans le navigateur : Le client frontend (navigateur) n’a jamais accès aux jetons JWT (access_token, refresh_token, id_token).
  2. Session scellée par cookie HttpOnly & SameSite : Le navigateur ne conserve qu’un cookie de session chiffré, inaccessible au JavaScript (httpOnly: true, secure: true, sameSite: 'lax').
  3. Token Handler côté serveur : C’est le serveur BFF qui conserve les jetons OIDC, les rafraîchit en arrière-plan et valide activement la survie de la session auprès de l’IAM.

2. Schéma d’Architecture Global (ASCII)

┌────────────────────────────────────────────────────────────────────────────────────────┐
│                                 NAVIGATEUR (CLIENT SPA)                                │
│  - Vue 3 / React / Nuxt                                                                │
│  - Cookie HttpOnly : aivo_session (Chiffré AES-256, max 500 octets)                    │
│  - Mémoire vive : Jeton anti-CSRF éphémère                                             │
└───────────────────┬──────────────────────────────────────────────▲─────────────────────┘
                    │                                              │
    (1) HTTPS POST  │ [Cookie: aivo_session]                       │ (8) 302 Redirection
    avec credentials│ [X-CSRF-Token: ...]                          │     Set-Cookie
                    ▼                                              │
┌──────────────────────────────────────────────────────────────────┴─────────────────────┐
│                             BACKEND-FOR-FRONTEND (BFF)                                 │
│  - Fastify / Express en Node.js TypeScript                                             │
│  - Chiffrement AES-256-GCM du cookie client                                            │
│  - Passerelle anti-CSRF stricte                                                        │
│                                                                                        │
│   ┌────────────────────────────────┐       ┌──────────────────────────────────────┐    │
│   │   Session Store (En Mémoire)   │ <===> │    PostgreSQL (user_sessions)        │    │
│   │   Cache < 1ms pour tokens OIDC │       │    Persistance inter-conteneurs      │    │
│   └───────────────▲────────────────┘       └──────────────────▲───────────────────┘    │
└───────────────────┼───────────────────────────────────────────┼────────────────────────┘
                    │                                           │
    (3) Échange Code│ (5) UserInfo / Token Refresh              │ (7) Backchannel Logout
        + PKCE S256 │     (Validation active de session)        │     (Révocation directe)
                    ▼                                           ▼
┌────────────────────────────────────────────────────────────────────────────────────────┐
│                           SERVEUR D'IDENTITÉ IAM (KEYCLOAK)                            │
│  - OpenID Connect 1.0 & OAuth 2.0 Identity Provider                                    │
│  - Single Sign-On (SSO) & Gestion des sessions actives du Realm                        │
└────────────────────────────────────────────────────────────────────────────────────────┘

Lorsqu’on bascule vers un BFF, l’erreur classique consiste à stocker directement les jetons OIDC (access_token, refresh_token, id_token) dans le cookie de session chiffré.

Pourquoi cela casse silencieusement votre application :

  • Un access_token JWT Keycloak complet pèse souvent entre 1 000 et 1 500 octets.
  • Un refresh_token pèse entre 900 et 1 200 octets.
  • Un id_token pèse entre 1 000 et 1 400 octets.
  • Total JSON brut : ~3,8 Ko.
  • Après chiffrement symétrique AES-256-GCM et encodage hexadécimal, la valeur du cookie atteint 7 700 à 8 000 octets !

Attention — La limite RFC 6265 des cookies HTTP :
Tous les navigateurs modernes (Chrome, Firefox, Safari, Edge) et les reverse-proxies (Traefik, Nginx) rejettent silencieusement tout cookie dont la taille dépasse 4 096 octets.

Symptôme typique : Lors de la connexion, le callback OIDC renvoie une redirection 302 avec le cookie géant. Le navigateur le jette à la poubelle sans avertissement visible. Dès la requête suivante (/api/auth/me), l’utilisateur reçoit une erreur 401 Unauthorized car aucun cookie n’a été transmis !

La Solution : Architecture Hybride Server-Side Session Store

  • Dans le cookie du navigateur : Uniquement un session_id cryptographique aléatoire (32 octets hex), les informations publiques du profil utilisateur (id, name, email, role) et le csrf_token.
    • Poids total : ~500 octets (moins de 15 % de la limite autorisée).
  • Sur le serveur BFF : Les jetons OIDC complets sont stockés dans un cache mémoire vive (temps de réponse < 1 ms) couplé à une base de données PostgreSQL (table user_sessions) pour résister aux redémarrages de conteneurs.

4. Implémentation Serveur : Code Prêt à l’Emploi

4.1. Schéma de la Base de Données (schema.sql)

-- Table de persistance des sessions souveraines côté BFF
CREATE TABLE IF NOT EXISTS user_sessions (
    session_id VARCHAR(128) PRIMARY KEY,
    user_id VARCHAR(255) NOT NULL,
    user_data JSONB NOT NULL,
    tokens JSONB NOT NULL,
    last_verified_at BIGINT DEFAULT 0,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id);

4.2. Le Store de Sessions Serveur (services/sessionStore.ts)

import pg from 'pg';

export interface UserInfo {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

export interface KeycloakTokens {
  access_token: string;
  refresh_token: string;
  id_token?: string;
  expires_at?: number;
}

export interface StoredSession {
  sessionId: string;
  userId: string;
  user: UserInfo;
  tokens: KeycloakTokens;
  lastVerifiedAt: number;
}

// Cache en mémoire vive ultra-rapide (< 1ms)
const memorySessions = new Map<string, StoredSession>();

// Instance de connexion PostgreSQL
export let pool: pg.Pool;

export function setDbPool(dbPool: pg.Pool) {
  pool = dbPool;
}

export async function saveSession(
  sessionId: string,
  user: UserInfo,
  tokens: KeycloakTokens
): Promise<void> {
  const record: StoredSession = {
    sessionId,
    userId: user.id,
    user,
    tokens,
    lastVerifiedAt: Date.now(),
  };

  // 1. Sauvegarde en mémoire vive
  memorySessions.set(sessionId, record);

  // 2. Persistance PostgreSQL
  if (pool) {
    try {
      await pool.query(
        `INSERT INTO user_sessions (session_id, user_id, user_data, tokens, last_verified_at, updated_at)
         VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
         ON CONFLICT (session_id) DO UPDATE SET
           user_data = EXCLUDED.user_data,
           tokens = EXCLUDED.tokens,
           last_verified_at = EXCLUDED.last_verified_at,
           updated_at = CURRENT_TIMESTAMP`,
        [sessionId, user.id, JSON.stringify(user), JSON.stringify(tokens), Date.now()]
      );
    } catch (err) {
      console.warn('[SessionStore] Erreur insertion SQL session:', err);
    }
  }
}

export async function getSession(sessionId: string): Promise<StoredSession | null> {
  if (!sessionId) return null;

  // 1. Lecture cache mémoire
  const cached = memorySessions.get(sessionId);
  if (cached) return cached;

  // 2. Lecture SQL si absent de la RAM (ex: après restart conteneur)
  if (pool) {
    try {
      const res = await pool.query(
        `SELECT session_id, user_id, user_data, tokens, last_verified_at 
         FROM user_sessions WHERE session_id = $1`,
        [sessionId]
      );
      if (res.rows && res.rows.length > 0) {
        const row = res.rows[0];
        const record: StoredSession = {
          sessionId: row.session_id,
          userId: row.user_id,
          user: typeof row.user_data === 'string' ? JSON.parse(row.user_data) : row.user_data,
          tokens: typeof row.tokens === 'string' ? JSON.parse(row.tokens) : row.tokens,
          lastVerifiedAt: Number(row.last_verified_at || 0),
        };
        memorySessions.set(sessionId, record);
        return record;
      }
    } catch (err) {
      console.warn('[SessionStore] Erreur lecture SQL session:', err);
    }
  }

  return null;
}

export async function updateSessionTokens(
  sessionId: string,
  tokens: KeycloakTokens
): Promise<void> {
  const session = await getSession(sessionId);
  if (!session) return;

  session.tokens = tokens;
  memorySessions.set(sessionId, session);

  if (pool) {
    try {
      await pool.query(
        `UPDATE user_sessions SET tokens = $1, updated_at = CURRENT_TIMESTAMP WHERE session_id = $2`,
        [JSON.stringify(tokens), sessionId]
      );
    } catch (err) {
      console.warn('[SessionStore] Erreur mise à jour tokens SQL:', err);
    }
  }
}

export async function updateLastVerifiedAt(sessionId: string): Promise<void> {
  const now = Date.now();
  const session = memorySessions.get(sessionId);
  if (session) {
    session.lastVerifiedAt = now;
  }

  if (pool) {
    try {
      await pool.query(
        `UPDATE user_sessions SET last_verified_at = $1 WHERE session_id = $2`,
        [now, sessionId]
      );
    } catch {}
  }
}

export async function deleteSession(sessionId: string): Promise<void> {
  memorySessions.delete(sessionId);

  if (pool) {
    try {
      await pool.query(`DELETE FROM user_sessions WHERE session_id = $1`, [sessionId]);
    } catch (err) {
      console.warn('[SessionStore] Erreur suppression session SQL:', err);
    }
  }
}

import fp from 'fastify-plugin';
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import crypto from 'crypto';

export interface UserInfo {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

export interface SessionData {
  session_id?: string;
  user?: UserInfo;
  csrf_token?: string;
  oidc_state?: string;
  oidc_nonce?: string;
  oidc_code_verifier?: string;
  [key: string]: any;
}

export class Session {
  private data: SessionData;
  private modified = false;

  constructor(initialData: SessionData = {}) {
    this.data = { ...initialData };
  }

  get<K extends keyof SessionData>(key: K): SessionData[K] {
    return this.data[key];
  }

  set<K extends keyof SessionData>(key: K, value: SessionData[K]): void {
    this.data[key] = value;
    this.modified = true;
  }

  delete(): void {
    this.data = {};
    this.modified = true;
  }

  isModified(): boolean {
    return this.modified;
  }

  toJSON(): SessionData {
    return this.data;
  }
}

declare module 'fastify' {
  interface FastifyRequest {
    session: Session;
  }
}

function encrypt(data: object, key: Buffer): string {
  const iv = crypto.randomBytes(12);
  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
  const json = JSON.stringify(data);
  let encrypted = cipher.update(json, 'utf8', 'hex');
  encrypted += cipher.final('hex');
  const tag = cipher.getAuthTag();
  return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted}`;
}

function decrypt(encryptedString: string, key: Buffer): object | null {
  try {
    const parts = encryptedString.split(':');
    if (parts.length !== 3) return null;
    const iv = Buffer.from(parts[0], 'hex');
    const tag = Buffer.from(parts[1], 'hex');
    const ciphertext = parts[2];
    const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
    decipher.setAuthTag(tag);
    let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
    decrypted += decipher.final('utf8');
    return JSON.parse(decrypted);
  } catch {
    return null;
  }
}

export default fp(async function sessionPlugin(fastify: FastifyInstance, opts: { secret: Buffer; cookieName?: string; maxAge?: number }) {
  const cookieName = opts.cookieName || 'app_session';
  const secretKey = opts.secret;
  const maxAge = opts.maxAge || 7 * 24 * 60 * 60; // 7 jours

  // Déchiffrement de la session sur chaque requête entrante
  fastify.addHook('onRequest', async (request: FastifyRequest) => {
    const cookieValue = request.cookies[cookieName];
    let initialData = {};
    if (cookieValue) {
      const decrypted = decrypt(cookieValue, secretKey);
      if (decrypted) initialData = decrypted;
    }
    request.session = new Session(initialData);
  });

  // Chiffrement ou purge du cookie lors de l'envoi de la réponse
  fastify.addHook('onSend', async (request: FastifyRequest, reply: FastifyReply) => {
    if (request.session && request.session.isModified()) {
      const sessionData = request.session.toJSON();
      
      let domain = process.env.COOKIE_DOMAIN;
      if (!domain) {
        const reqHost = (request.headers['x-forwarded-host'] || request.headers.host || '') as string;
        const cleanHost = reqHost.split(':')[0];
        if (cleanHost.endsWith('example.com')) {
          domain = '.example.com';
        }
      }

      const isSecure = process.env.COOKIE_SECURE === 'true' || request.protocol === 'https' || process.env.NODE_ENV === 'production';
      const sameSiteOption = (process.env.COOKIE_SAMESITE as 'lax' | 'none' | 'strict') || 'lax';

      if (Object.keys(sessionData).length === 0) {
        // Purge stricte : attributs identiques pour que le navigateur supprime bien le cookie
        const clearOptions = {
          path: '/',
          httpOnly: true,
          secure: isSecure,
          sameSite: sameSiteOption,
        };
        if (domain) {
          reply.clearCookie(cookieName, { ...clearOptions, domain });
        }
        reply.clearCookie(cookieName, clearOptions);
      } else {
        const encrypted = encrypt(sessionData, secretKey);
        reply.setCookie(cookieName, encrypted, {
          path: '/',
          httpOnly: true,
          secure: isSecure,
          sameSite: sameSiteOption,
          domain,
          maxAge,
        });
      }
    }
  });
});

4.4. Service de Cycle de Vie OIDC (services/keycloakService.ts)

import { FastifyBaseLogger } from 'fastify';
import { Session, UserInfo } from '../plugins/session.js';
import { 
  getSession, 
  updateSessionTokens, 
  updateLastVerifiedAt, 
  deleteSession, 
  KeycloakTokens 
} from './sessionStore.js';

export function getKeycloakConfig() {
  const rawKeycloakUrl = process.env.KEYCLOAK_URL;
  return {
    keycloakUrl: rawKeycloakUrl ? rawKeycloakUrl.replace(/\/+$/, '') : undefined,
    realm: process.env.KEYCLOAK_REALM || 'main',
    clientId: process.env.KEYCLOAK_CLIENT_ID || 'app-spa',
    clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
    redirectUri: process.env.REDIRECT_URI || 'http://localhost:3000/api/auth/callback',
    frontendUrl: (process.env.FRONTEND_URL || 'http://localhost:5173').replace(/\/+$/, ''),
  };
}

/**
 * Validation active de la session auprès de Keycloak (UserInfo) avec rafraîchissement transparent
 */
export async function verifyOrRefreshKeycloakSession(
  session: Session,
  log?: FastifyBaseLogger
): Promise<boolean> {
  const { keycloakUrl, realm, clientId, clientSecret } = getKeycloakConfig();
  if (!keycloakUrl) return true; // Mode mock

  const sessionId = session.get('session_id');
  if (!sessionId) return false;

  const stored = await getSession(sessionId);
  if (!stored || !stored.tokens || !stored.tokens.access_token) return false;

  const { tokens } = stored;
  const now = Date.now();
  const isAccessTokenExpired = tokens.expires_at ? now >= tokens.expires_at : false;

  // Cache court de 15 secondes pour éviter de surcharger Keycloak sur des clics rapprochés
  if (now - (stored.lastVerifiedAt || 0) < 15000 && !isAccessTokenExpired) {
    return true;
  }

  const userInfoEndpoint = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/userinfo`;

  // 1. Test du jeton d'accès actuel auprès de UserInfo
  if (!isAccessTokenExpired) {
    try {
      const userRes = await fetch(userInfoEndpoint, {
        headers: { Authorization: `Bearer ${tokens.access_token}` },
      });

      if (userRes.ok) {
        const rawUser = await userRes.json() as any;
        if (rawUser && rawUser.sub) {
          const currentUser = session.get('user') || stored.user;
          session.set('user', {
            id: rawUser.sub,
            name: rawUser.name || rawUser.preferred_username || currentUser?.name || 'Utilisateur',
            email: rawUser.email || currentUser?.email || '',
            role: (rawUser.roles?.includes('admin') || currentUser?.role === 'admin') ? 'admin' : 'user',
          });
        }
        await updateLastVerifiedAt(sessionId);
        return true;
      }
      log?.info('Keycloak UserInfo invalide (jeton expiré ou session supprimée côté IAM). Tentative de refresh...');
    } catch (err) {
      log?.warn({ err }, 'Erreur réseau vers Keycloak UserInfo');
    }
  }

  // 2. Si le jeton est expiré ou que UserInfo a échoué, rafraîchir avec le refresh_token
  if (!tokens.refresh_token) {
    await deleteSession(sessionId);
    return false;
  }

  try {
    const tokenEndpoint = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/token`;
    const body = new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: tokens.refresh_token,
      client_id: clientId,
    });
    if (clientSecret) body.append('client_secret', clientSecret);

    const refreshRes = await fetch(tokenEndpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: body.toString(),
    });

    if (!refreshRes.ok) {
      log?.warn('La session a été révoquée ou supprimée dans Keycloak. Purge de la session locale.');
      await deleteSession(sessionId);
      return false;
    }

    const rawNewTokens = await refreshRes.json() as any;
    const newTokens: KeycloakTokens = {
      access_token: rawNewTokens.access_token,
      refresh_token: rawNewTokens.refresh_token || tokens.refresh_token,
      id_token: rawNewTokens.id_token || tokens.id_token,
      expires_at: Date.now() + ((rawNewTokens.expires_in || 300) * 1000),
    };

    await updateSessionTokens(sessionId, newTokens);
    await updateLastVerifiedAt(sessionId);
    return true;
  } catch (err) {
    log?.error({ err }, 'Exception critique lors du rafraîchissement Keycloak');
    await deleteSession(sessionId);
    return false;
  }
}

/**
 * Termine la session : Appel serveur Backchannel + Révocation RFC 7009 + URL frontchannel
 */
export async function revokeKeycloakSession(
  session: Session,
  log?: FastifyBaseLogger
): Promise<string> {
  const { keycloakUrl, realm, clientId, clientSecret, frontendUrl } = getKeycloakConfig();
  let logoutUrl = `${frontendUrl}/login`;
  if (!keycloakUrl) return logoutUrl;

  const sessionId = session.get('session_id');
  const stored = sessionId ? await getSession(sessionId) : null;
  const tokens = stored?.tokens;

  // 1. Déconnexion Backchannel directe (serveur à serveur)
  if (tokens?.refresh_token) {
    try {
      const backchannelEndpoint = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/logout`;
      const body = new URLSearchParams({
        client_id: clientId,
        refresh_token: tokens.refresh_token,
      });
      if (clientSecret) body.append('client_secret', clientSecret);

      await fetch(backchannelEndpoint, {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: body.toString(),
      });
      log?.info('Keycloak Backchannel logout exécuté');
    } catch (err) {
      log?.warn({ err }, 'Échec de la requête Keycloak backchannel logout');
    }

    // 2. Révocation explicite RFC 7009 du refresh token
    try {
      const revokeEndpoint = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/revoke`;
      const revokeBody = new URLSearchParams({
        client_id: clientId,
        token: tokens.refresh_token,
        token_type_hint: 'refresh_token',
      });
      if (clientSecret) revokeBody.append('client_secret', clientSecret);

      await fetch(revokeEndpoint, {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: revokeBody.toString(),
      });
    } catch {}
  }

  // Purge côté serveur local
  if (sessionId) await deleteSession(sessionId);

  // 3. Construction de l'URL OIDC RP-Initiated Logout frontchannel
  const logoutParams = new URLSearchParams();
  if (tokens?.id_token) logoutParams.append('id_token_hint', tokens.id_token);
  logoutParams.append('client_id', clientId);
  logoutParams.append('post_logout_redirect_uri', `${frontendUrl}/login`);

  return `${keycloakUrl}/realms/${realm}/protocol/openid-connect/logout?${logoutParams.toString()}`;
}

4.5. Routes d’Authentification (routes/auth.ts)

import { FastifyInstance } from 'fastify';
import crypto from 'crypto';
import { UserInfo } from '../plugins/session.js';
import { 
  verifyOrRefreshKeycloakSession, 
  revokeKeycloakSession, 
  KeycloakTokens,
  getKeycloakConfig 
} from '../services/keycloakService.js';
import { saveSession } from '../services/sessionStore.js';

export async function authRoutes(fastify: FastifyInstance) {
  const { keycloakUrl, realm, clientId, clientSecret, redirectUri, frontendUrl } = getKeycloakConfig();
  const isMockMode = !keycloakUrl;

  // 1. Initialisation du flux OIDC avec PKCE (RFC 7636)
  fastify.get('/login', async (request, reply) => {
    if (isMockMode) {
      const state = crypto.randomBytes(16).toString('hex');
      request.session.set('oidc_state', state);
      return reply.redirect(`/api/auth/callback?code=mock_code&state=${state}`);
    }

    const state = crypto.randomBytes(16).toString('hex');
    const nonce = crypto.randomBytes(16).toString('hex');
    const codeVerifier = crypto.randomBytes(32).toString('base64url');
    const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');

    request.session.set('oidc_state', state);
    request.session.set('oidc_nonce', nonce);
    request.session.set('oidc_code_verifier', codeVerifier);

    const authUrl = new URL(`${keycloakUrl}/realms/${realm}/protocol/openid-connect/auth`);
    authUrl.searchParams.append('client_id', clientId);
    authUrl.searchParams.append('redirect_uri', redirectUri);
    authUrl.searchParams.append('response_type', 'code');
    authUrl.searchParams.append('scope', 'openid email profile');
    authUrl.searchParams.append('state', state);
    authUrl.searchParams.append('nonce', nonce);
    authUrl.searchParams.append('code_challenge', codeChallenge);
    authUrl.searchParams.append('code_challenge_method', 'S256');
    authUrl.searchParams.append('prompt', 'login');

    return reply.redirect(authUrl.toString());
  });

  // 2. Callback de retour OIDC : échange de code et création de la session
  fastify.get('/callback', async (request, reply) => {
    const { code, state, error, error_description } = request.query as any;

    if (error) {
      return reply.code(400).send({ error: 'Auth Error', message: error_description || error });
    }

    const savedState = request.session.get('oidc_state');
    const codeVerifier = request.session.get('oidc_code_verifier');

    if (savedState && state && state !== savedState) {
      return reply.code(400).send({ error: 'Bad Request', message: 'Paramètre State invalide.' });
    }

    let userInfo: UserInfo;
    let tokens: KeycloakTokens;

    if (isMockMode) {
      userInfo = { id: 'user_001', name: 'Admin Demo', email: 'admin@example.com', role: 'admin' };
      tokens = { access_token: 'mock_token', refresh_token: 'mock_refresh', expires_at: Date.now() + 3600000 };
    } else {
      const tokenEndpoint = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/token`;
      const body = new URLSearchParams({
        grant_type: 'authorization_code',
        code: code || '',
        redirect_uri: redirectUri,
        client_id: clientId,
      });
      if (clientSecret) body.append('client_secret', clientSecret);
      if (codeVerifier) body.append('code_verifier', codeVerifier);

      const tokenRes = await fetch(tokenEndpoint, {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: body.toString(),
      });

      if (!tokenRes.ok) {
        throw new Error(`Échec échange token Keycloak (${tokenRes.status})`);
      }

      const rawTokens = await tokenRes.json() as any;
      tokens = {
        access_token: rawTokens.access_token,
        refresh_token: rawTokens.refresh_token,
        id_token: rawTokens.id_token,
        expires_at: Date.now() + ((rawTokens.expires_in || 300) * 1000),
      };

      const userInfoRes = await fetch(`${keycloakUrl}/realms/${realm}/protocol/openid-connect/userinfo`, {
        headers: { Authorization: `Bearer ${tokens.access_token}` },
      });

      const rawUser = await userInfoRes.json() as any;
      userInfo = {
        id: rawUser.sub,
        name: rawUser.name || rawUser.preferred_username || 'Utilisateur',
        email: rawUser.email || '',
        role: rawUser.roles?.includes('admin') ? 'admin' : 'user',
      };
    }

    // Création de l'ID de session unique et stockage des tokens côté serveur
    const sessionId = crypto.randomBytes(32).toString('hex');
    await saveSession(sessionId, userInfo, tokens);

    // Génération du jeton anti-CSRF
    const csrfToken = crypto.randomBytes(32).toString('hex');

    request.session.set('session_id', sessionId);
    request.session.set('user', userInfo);
    request.session.set('csrf_token', csrfToken);

    return reply.redirect(frontendUrl);
  });

  // 3. Endpoint de vérification de session active (/me)
  fastify.get('/me', async (request, reply) => {
    const user = request.session.get('user');
    const csrfToken = request.session.get('csrf_token');

    if (!user) {
      return reply.code(401).send({ authenticated: false, message: 'Non authentifié.' });
    }

    // Validation active auprès de Keycloak
    if (keycloakUrl) {
      const isSessionActive = await verifyOrRefreshKeycloakSession(request.session, request.log);
      if (!isSessionActive) {
        request.session.delete();
        return reply.code(401).send({ 
          authenticated: false, 
          code: 'SESSION_REVOKED',
          message: 'Session Keycloak révoquée ou expirée.' 
        });
      }
    }

    return {
      authenticated: true,
      user: request.session.get('user'),
      csrfToken: request.session.get('csrf_token') || csrfToken,
    };
  });

  // 4. Déconnexion universelle (POST & GET)
  const handleLogout = async (request: any, reply: any) => {
    const logoutUrl = await revokeKeycloakSession(request.session, request.log);
    request.session.delete();
    return { success: true, logoutUrl };
  };

  fastify.post('/logout', async (request, reply) => handleLogout(request, reply));
  fastify.get('/logout', async (request, reply) => {
    const { logoutUrl } = await handleLogout(request, reply);
    return reply.redirect(logoutUrl);
  });
}

4.6. Serveur Principal, Anti-CSRF et Parser JSON (server.ts)

import Fastify from 'fastify';
import cors from '@fastify/cors';
import cookie from '@fastify/cookie';
import crypto from 'crypto';
import sessionPlugin from './plugins/session.js';
import { authRoutes } from './routes/auth.js';
import { verifyOrRefreshKeycloakSession } from './services/keycloakService.js';

const isProduction = process.env.NODE_ENV === 'production';

const server = Fastify({
  trustProxy: true, // Crucial pour Traefik / Nginx (HTTPS, X-Forwarded-Host)
  logger: isProduction ? true : { level: 'info' },
});

// TOLÉRER LES CORPS JSON VIDES SUR POST (évite l'erreur FST_ERR_CTP_EMPTY_JSON_BODY)
server.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
  if (!body || (typeof body === 'string' && body.trim() === '')) {
    return done(null, {});
  }
  try {
    const json = JSON.parse(body as string);
    done(null, json);
  } catch (err: any) {
    err.statusCode = 400;
    done(err, undefined);
  }
});

await server.register(cors, {
  origin: ['https://app.example.com', 'http://localhost:5173'],
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
});

await server.register(cookie);

const sessionSecret = crypto.createHash('sha256').update(process.env.SESSION_SECRET || 'fallback_secret_32bytes').digest();
await server.register(sessionPlugin, {
  cookieName: 'app_session',
  secret: sessionSecret,
  maxAge: 7 * 24 * 60 * 60,
});

// GARDIEN DE SÉCURITÉ GLOBAL DU BFF
server.addHook('preHandler', async (request, reply) => {
  const rawUrl = request.raw.url || request.url;
  const path = rawUrl.split('?')[0];
  const method = request.method;

  const isPublic = 
    path === '/api/health' ||
    path === '/api/auth/login' ||
    path === '/api/auth/callback' ||
    path === '/api/auth/me' ||
    path === '/api/auth/logout';

  // 1. Les routes publiques ne nécessitent pas de validation
  if (isPublic || !isProduction) return;

  // 2. Vérification de la session locale pour les routes privées
  const user = request.session?.get('user');
  if (!user) {
    return reply.code(401).send({ error: 'Unauthorized', code: 'AUTH_REQUIRED' });
  }

  // 3. Validation de session Keycloak si configurée
  const sessionId = request.session?.get('session_id');
  if (process.env.KEYCLOAK_URL && sessionId) {
    const isAlive = await verifyOrRefreshKeycloakSession(request.session, request.log);
    if (!isAlive) {
      request.session.delete();
      return reply.code(401).send({ error: 'Unauthorized', code: 'KEYCLOAK_SESSION_EXPIRED' });
    }
  }

  // 4. Protection Anti-CSRF stricte sur toutes les mutations (POST, PUT, PATCH, DELETE)
  if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
    const sessionCsrf = request.session.get('csrf_token');
    const headerCsrf = request.headers['x-csrf-token'];

    if (!sessionCsrf || !headerCsrf || sessionCsrf !== headerCsrf) {
      return reply.code(403).send({ error: 'Forbidden', code: 'CSRF_INVALID' });
    }
  }
});

// Enregistrement des routes
await server.register(authRoutes, { prefix: '/api/auth' });

await server.listen({ port: 3000, host: '0.0.0.0' });

5. Implémentation Client SPA (Vue 3 / React)

5.1. Composable d’Authentification (composables/useAuth.ts)

import { ref } from 'vue';

export interface UserProfile {
  id: string;
  name: string;
  email: string;
  role: string;
}

const user = ref<UserProfile | null>(null);
const isAuthenticated = ref<boolean>(false);
const csrfToken = ref<string>('');
const loading = ref<boolean>(true);

export function useAuth() {
  /**
   * Vérifie la session auprès du BFF (activement validée auprès de Keycloak)
   */
  async function checkAuth(): Promise<boolean> {
    loading.value = true;
    try {
      const res = await fetch('/api/auth/me', { credentials: 'include' });
      if (res.ok) {
        const data = await res.json();
        if (data.authenticated && data.user) {
          user.value = data.user;
          csrfToken.value = data.csrfToken || '';
          isAuthenticated.value = true;
          return true;
        }
      }
      user.value = null;
      csrfToken.value = '';
      isAuthenticated.value = false;
      return false;
    } catch {
      user.value = null;
      csrfToken.value = '';
      isAuthenticated.value = false;
      return false;
    } finally {
      loading.value = false;
    }
  }

  function login() {
    window.location.href = '/api/auth/login';
  }

  /**
   * Déconnexion sécurisée : purge locale + révocation serveur + redirection SSO
   */
  async function logout() {
    try {
      const res = await fetch('/api/auth/logout', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-CSRF-Token': csrfToken.value,
        },
        body: JSON.stringify({}), // Corps explicite obligatoire
        credentials: 'include',
      });

      user.value = null;
      isAuthenticated.value = false;
      csrfToken.value = '';

      if (res.ok) {
        const data = await res.json();
        if (data.logoutUrl) {
          window.location.href = data.logoutUrl;
          return;
        }
      }
      window.location.href = '/login';
    } catch {
      user.value = null;
      isAuthenticated.value = false;
      csrfToken.value = '';
      window.location.href = '/login';
    }
  }

  /**
   * Wrapper fetch sécurisé avec injection automatique du cookie HttpOnly et du CSRF
   */
  async function apiFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
    const headers = new Headers(init.headers || {});
    
    if (init.method && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(init.method.toUpperCase())) {
      if (csrfToken.value) {
        headers.set('X-CSRF-Token', csrfToken.value);
      }
    }

    const response = await fetch(input, { ...init, headers, credentials: 'include' });

    if (response.status === 401) {
      isAuthenticated.value = false;
      user.value = null;
      csrfToken.value = '';
      if (window.location.pathname !== '/login') {
        window.location.href = '/login';
      }
    }

    return response;
  }

  return {
    user,
    isAuthenticated,
    csrfToken,
    loading,
    checkAuth,
    login,
    logout,
    apiFetch,
  };
}

5.2. Garde de Navigation du Routeur (router/index.ts)

import { createRouter, createWebHistory } from 'vue-router';
import { useAuth } from '../composables/useAuth';

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/login', component: () => import('../views/LoginView.vue'), meta: { public: true } },
    { path: '/dashboard', component: () => import('../views/DashboardView.vue') },
    { path: '/', redirect: '/dashboard' },
  ],
});

router.beforeEach(async (to, _from, next) => {
  const { checkAuth } = useAuth();

  if (to.meta.public) {
    if (to.path === '/login') {
      const isAuthed = await checkAuth();
      if (isAuthed) return next({ path: '/dashboard' });
    }
    return next();
  }

  // Validation active systématique : si la session est révoquée dans Keycloak,
  // l'utilisateur est instantanément expulsé vers /login
  const isAuthed = await checkAuth();
  if (!isAuthed) {
    return next({ path: '/login', query: { redirect: to.fullPath } });
  }

  next();
});

export default router;

6. Diagrammes des Scénarios de Sécurité

Scénario 1 : Révocation Administrative depuis la Console Keycloak

Admin Keycloak            Client Navigateur             Fastify BFF              Keycloak IAM
      │                           │                          │                        │
      ├─ Supprime la session ────┼──────────────────────────┼───────────────────────►│ (Session détruite)
      │                           │                          │                        │
      │                     (Recharge F5 ou                  │                        │
      │                      clic onglet)                    │                        │
      │                           │                          │                        │
      │                           ├─ GET /api/auth/me ──────►│                        │
      │                           │  [Cookie aivo_session]   ├─ GET /userinfo ───────►│
      │                           │                          │  Bearer <access_token> │
      │                           │                          │                        │
      │                           │                          │◄─ 401 Unauthorized ────┤ (Token invalide)
      │                           │                          │                        │
      │                           │                          ├─ POST /token (refresh)►│
      │                           │                          │  grant_type=refresh    │
      │                           │                          │                        │
      │                           │                          │◄─ 400 Bad Request ─────┤ (invalid_grant:
      │                           │                          │                        │  Session not active)
      │                           │                          ├─ request.session.delete()
      │                           │                          ├─ deleteSession(id)     │
      │                           │                          │                        │
      │                           │◄─ 401 Unauthorized ──────┤                        │
      │                           │   Set-Cookie: Max-Age=0  │                        │
      │                           │                          │                        │
      │                  (Cookie purgé du                    │                        │
      │                   navigateur + État                  │                        │
      │                   local reset)                       │                        │
      │                           │                          │                        │
      │                           ├─ Redirection /login ─────┤                        │

Scénario 2 : Déconnexion Volontaire (Bouton Déconnexion)

Utilisateur              Client Navigateur              Fastify BFF              Keycloak IAM
     │                           │                           │                        │
     ├─ Clic Déconnexion ───────►│                           │                        │
     │                           ├─ POST /api/auth/logout ──►│                        │
     │                           │  Body: {}                 │                        │
     │                           │  [Cookie aivo_session]    ├─ POST /logout ────────►│ (Session révoquée)
     │                           │                           │  (Backchannel direct)  │
     │                           │                           ├─ POST /revoke ────────►│ (Refresh révoqué)
     │                           │                           ├─ deleteSession(id)     │
     │                           │                           ├─ request.session.delete()
     │                           │                           │                        │
     │                           │◄─ 200 OK ─────────────────┤                        │
     │                           │   Set-Cookie: Max-Age=0   │                        │
     │                           │   { logoutUrl: "..." }    │                        │
     │                           │                           │                        │
     │                  (Purge cookie client)                │                        │
     │                           │                           │                        │
     │                           ├─ Redirection Navigateur ──────────────────────────►│ (Purge cookies SSO)
     │                           │  vers logoutUrl OIDC      │                        │
     │                           │                           │                        │
     │                           │◄─ 302 Redirection vers /login ─────────────────────┤
     │                           │                           │                        │
     │                  (Affiche LoginView)                  │                        │

7. Checklist de Déploiement en Production

Point de ContrôleRecommandationJustification
Reverse Proxy (Traefik / Nginx)trustProxy: true dans FastifyPermet la détection du protocole HTTPS et de l’en-tête X-Forwarded-Host.
Cookie Domain.example.com (avec point initial)Permet au cookie d’être partagé entre app.example.com et api.example.com.
SameSite CookieSameSite: LaxÉvite le blocage des cookies tiers par Safari (ITP) tout en garantissant la transmission lors des 302 post-login.
Poids du CookieStrictement < 1 KoEmpêche tout rejet silencieux du cookie par les navigateurs (limite RFC 6265 à 4 Ko).
Keycloak Client SettingsRenseigner Valid Post Logout Redirect URIsÉvite l’erreur Invalid parameter: post_logout_redirect_uri lors du retour OIDC.
Parser FastifyTolérer les corps vides sur application/jsonPrévient les erreurs 400 FST_ERR_CTP_EMPTY_JSON_BODY sur les requêtes de déconnexion.
Protection Anti-CSRFEn-tête synchronisé X-CSRF-TokenImpératif sur toutes les requêtes mutantes (POST, PUT, DELETE) utilisant des cookies avec credentials: include.

8. Dépôt Starter Open-Source : Lancez la Démo en 1 Minute

Pour vous permettre de tester cette architecture en conditions réelles sans réécrire l’ensemble du code, un dépôt boilerplate complet clé en main est disponible :

👉 Dépôt GitHub : pure-bff-token-handler-starter

Il orchestre l’ensemble de la pile via un simple docker compose up :

  • PostgreSQL 16 (persistance des sessions souveraines).
  • Keycloak 24 (avec import automatique du realm pure-bff-demo et comptes de test demo/demo et admin/admin).
  • Serveur BFF Fastify (Token Handler, chiffrement AES-256-GCM, hook anti-CSRF).
  • Client Vue 3 + Vite (zéro jeton dans le navigateur, gestion fine de session).

Envie d’aller plus loin ?
Ce socle est nativement compatible avec Authelia, Authentik, Okta et Microsoft Entra ID grâce à l’implémentation de la spécification OIDC Discovery 1.0.
Abonnez-vous à nos publications sur notre blog AIVO ou notre Substack AIVO Tech Insights pour recevoir nos prochains guides d’architecture sur la souveraineté numérique et le Zero-Trust.

#Sécurité #Architecture #Keycloak #OIDC #BFF #Fastify #VueJS
Nicolas BAUD

Nicolas BAUD

Fractional CTO & Architecte IA/SI • Conseil AMOA
Fondateur & Lead Architecte chez AIVO

Architecte de solutions et Fractional CTO fort de plus de 15 ans d'expérience, Nicolas BAUD accompagne les organisations dans la sécurisation de leur SI, le déploiement d'architectures d'IA souveraines et la sobriété numérique.

Sommaire