Cybersécurité

Zitadel flux OIDC complet : tutoriel 2026 (SPA + backend)

12 min de lecture

Implémenter le flux OIDC Authorization Code + PKCE complet avec Zitadel dans une app SPA en 2026 (informations vérifiées en avril 2026, susceptibles d’évoluer).

Voir notre guide Zitadel.

Configurer l’application

  1. Console Zitadel → Project → Add Application
  2. Type : User Agent (SPA)
  3. Auth method : PKCE
  4. Redirect URIs : https://app.exemple.sn/auth/callback
  5. Post Logout URIs : https://app.exemple.sn/
  6. Récupérer Client ID

Frontend JS avec oidc-client-ts

// npm install oidc-client-ts
import { UserManager } from "oidc-client-ts";

const userManager = new UserManager({
  authority: "https://auth.exemple.sn",
  client_id: "VOTRE_CLIENT_ID",
  redirect_uri: "https://app.exemple.sn/auth/callback",
  post_logout_redirect_uri: "https://app.exemple.sn/",
  response_type: "code",
  scope: "openid profile email offline_access",
});

// Login
async function login() {
  await userManager.signinRedirect();
}

// Callback handler (page /auth/callback)
async function handleCallback() {
  const user = await userManager.signinRedirectCallback();
  console.log("Connecté :", user.profile);
  // user.access_token pour appeler vos API
  window.location.href = "/dashboard";
}

// Récupérer user en cours
async function getUser() {
  const user = await userManager.getUser();
  return user;
}

// Logout
async function logout() {
  await userManager.signoutRedirect();
}

Backend : valider le token

// Node.js avec jose
import * as jose from "jose";

const JWKS = jose.createRemoteJWKSet(
  new URL("https://auth.exemple.sn/oauth/v2/keys")
);

async function validateToken(token: string) {
  const { payload } = await jose.jwtVerify(token, JWKS, {
    issuer: "https://auth.exemple.sn",
    audience: "VOTRE_CLIENT_ID",
  });
  return payload;
}

// Middleware Hono
app.use("/api/*", async (c, next) => {
  const auth = c.req.header("Authorization");
  if (!auth?.startsWith("Bearer ")) return c.json({error: "Unauthorized"}, 401);
  
  try {
    const claims = await validateToken(auth.substring(7));
    c.set("user", claims);
    await next();
  } catch (e) {
    return c.json({error: "Invalid token"}, 401);
  }
});

Refresh token

oidc-client-ts gère automatiquement le refresh des tokens si vous demandez le scope offline_access. Activez aussi « Silent renew » dans la config UserManager pour rafraîchir avant expiration.

Pour approfondir

Besoin d’un VPS ou d’un hébergement fiable ?

Hostinger propose des plans abordables — adaptés aux tutoriels de ce blog et utilisés par notre rédaction. Le lien est un lien de partenariat : si vous achetez via lui, le blog reçoit une petite commission sans surcoût pour vous.

Voir les offres Hostinger →

Lien d affiliation. Si vous achetez via ce lien, le blog reçoit une petite commission sans surcoût pour vous.

Etape 1 : preparer un projet Zitadel pour votre app SPA + backend

Avant d’ecrire la moindre ligne de code, on configure proprement Zitadel. C’est l’etape la plus negligee, et c’est celle qui cause 80 % des erreurs OIDC quand on debogue plus tard. Zitadel est une plateforme open source d’identite (Identity Provider) compatible OIDC et SAML, hebergee soit en cloud (zitadel.cloud) soit en self-hosted. Pour une equipe basee a Dakar, Abidjan, Lome ou Cotonou, le tier gratuit cloud (jusqu’a 25 000 authentifications par mois) couvre largement un MVP.

Connectez-vous a la console Zitadel, creez une organisation (par exemple « ITSkillsCenter SN »), puis un projet (par exemple « App-Production »). Dans le projet, ajoutez deux applications distinctes : une de type « User Agent » pour la SPA (React, Vue, Angular) avec PKCE active, et une de type « API » pour le backend (Node, Go, Python).

# Reperer l'issuer URL de votre instance Zitadel
# Cloud: https://VOTRE-INSTANCE.zitadel.cloud
# Self-hosted: https://auth.votredomaine.com
curl https://VOTRE-INSTANCE.zitadel.cloud/.well-known/openid-configuration | jq .issuer

La commande renvoie l’URL canonique de l’issuer. Notez-la : elle servira a tous les clients OIDC. Si jq n’est pas installe, ajoutez-le avec sudo apt install jq sur Ubuntu/Debian, ou brew install jq sur macOS.

Etape 2 : configurer le client SPA avec PKCE et les redirect URI

Le flux Authorization Code + PKCE est aujourd’hui le seul flux recommande pour une SPA. Le flux Implicit est deprecie depuis OAuth 2.1 (draft IETF de 2024) car il expose le token dans l’URL. Dans la console Zitadel, ouvrez l’app SPA, onglet Configuration. Cochez « PKCE » comme methode d’authentification, et « Code » comme grant type. Decochez tout le reste.

Dans Redirect URIs, ajoutez vos URLs de callback : http://localhost:5173/callback pour le dev local Vite, et https://app.votredomaine.com/callback pour la prod. Dans Post Logout Redirect URIs, ajoutez les memes hosts sans le path /callback. Activez « Development Mode » uniquement le temps du dev (autorise les URLs http localhost).

# Tester le endpoint authorization manuellement
ISSUER="https://VOTRE-INSTANCE.zitadel.cloud"
CLIENT_ID="123456789@app-production"
REDIRECT="http://localhost:5173/callback"
echo "$ISSUER/oauth/v2/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$REDIRECT&scope=openid+profile+email&code_challenge=TEST&code_challenge_method=S256"

Collez l’URL generee dans un navigateur. Vous devez voir l’ecran de login Zitadel. Si vous voyez « redirect URI mismatch », c’est que l’URL exacte (avec le port et le path) n’est pas declaree dans la console. La verification est stricte : http://localhost:5173 et http://localhost:5173/ sont deux URLs differentes pour OIDC.

Etape 3 : implementer le flux Authorization Code dans la SPA (oidc-client-ts)

Pour le frontend, on utilise oidc-client-ts (successeur maintenu de oidc-client-js). Il gere PKCE, le silent refresh via iframe, et le stockage des tokens. Installez-le dans votre projet React/Vue :

npm install oidc-client-ts

Cree un fichier src/auth/userManager.ts qui expose une instance UserManager partagee. Le code suivant est minimal mais correct pour la production :

import { UserManager, WebStorageStateStore } from 'oidc-client-ts';

export const userManager = new UserManager({
  authority: import.meta.env.VITE_OIDC_ISSUER,
  client_id: import.meta.env.VITE_OIDC_CLIENT_ID,
  redirect_uri: window.location.origin + '/callback',
  post_logout_redirect_uri: window.location.origin,
  response_type: 'code',
  scope: 'openid profile email offline_access',
  userStore: new WebStorageStateStore({ store: window.localStorage }),
  automaticSilentRenew: true,
});

Le scope offline_access est essentiel : sans lui, Zitadel n’emet pas de refresh token, et la session expire au bout d’une heure sans pouvoir etre prolongee. Le flag automaticSilentRenew declenche un renouvellement automatique 60 secondes avant expiration de l’access token.

Etape 4 : gerer le callback et stocker la session

Quand Zitadel redirige l’utilisateur vers /callback?code=...&state=..., il faut echanger ce code contre des tokens. Cree une route /callback qui appelle userManager.signinRedirectCallback(). Cette methode lit le code dans l’URL, fait un POST authentifie vers le token endpoint, valide le state (anti-CSRF) et le code_verifier (PKCE), puis stocke le User dans localStorage.

// src/routes/Callback.tsx
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { userManager } from '../auth/userManager';

export function Callback() {
  const navigate = useNavigate();
  useEffect(() => {
    userManager.signinRedirectCallback()
      .then(() => navigate('/dashboard'))
      .catch(err => console.error('OIDC callback error', err));
  }, [navigate]);
  return <div>Connexion en cours...</div>;
}

Si vous voyez « No matching state found in storage », c’est que le state genere a l’authorize n’a pas ete persiste. Cause frequente : un mode Incognito strict, ou un localStorage purge entre l’authorize et le callback. Verifier dans DevTools > Application > Local Storage qu’une cle oidc.xxxxx existe avant le retour.

Etape 5 : appeler le backend avec l’access token

Une fois connecte, le backend doit recevoir l’access token dans chaque requete via l’en-tete Authorization: Bearer <token>. Wrap fetch dans un helper qui injecte le token automatiquement et tente un silent renew si l’expiration approche :

export async function apiFetch(path: string, init: RequestInit = {}) {
  const user = await userManager.getUser();
  if (!user || user.expired) {
    await userManager.signinSilent();
  }
  const fresh = await userManager.getUser();
  return fetch(import.meta.env.VITE_API_URL + path, {
    ...init,
    headers: {
      ...init.headers,
      Authorization: 'Bearer ' + fresh.access_token,
    },
  });
}

Le succes se mesure par un appel reseau qui retourne 200 avec le payload metier, et un access token rotatif visible dans DevTools > Network. Si vous voyez 401, soit le token est expire (renew silencieux a echoue), soit l’audience ne correspond pas a celle que le backend attend (voir Etape 6).

Etape 6 : valider le token cote backend (Node + jose)

Le backend ne doit jamais faire confiance a un token sans le valider cryptographiquement. Utilisez la librairie jose qui supporte JWKS (JSON Web Key Set) et le cache automatique des cles publiques. Installez-la :

npm install jose

Le middleware Express ressemble a ceci :

import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(
  new URL(process.env.OIDC_ISSUER + '/oauth/v2/keys')
);

export async function authMiddleware(req, res, next) {
  const auth = req.headers.authorization;
  if (!auth?.startsWith('Bearer ')) return res.status(401).end();
  try {
    const { payload } = await jwtVerify(auth.slice(7), JWKS, {
      issuer: process.env.OIDC_ISSUER,
      audience: process.env.OIDC_PROJECT_ID,
    });
    req.user = payload;
    next();
  } catch (e) {
    res.status(401).json({ error: 'invalid_token' });
  }
}

L’audience doit etre l’ID du projet Zitadel (visible dans l’URL de la console), pas l’ID du client. C’est l’erreur la plus courante. La signature est verifiee par RS256 contre la cle publique recuperee automatiquement depuis le JWKS endpoint.

Etape 7 : gerer la deconnexion et le refresh token

La deconnexion doit etre globale (RP-initiated logout selon la spec OIDC). Appelez userManager.signoutRedirect() qui redirige vers le end_session_endpoint de Zitadel. L’utilisateur est deconnecte cote IdP, puis renvoye vers post_logout_redirect_uri.

Pour le refresh, oidc-client-ts gere automatiquement le silent renew via iframe cachee. Si la sandbox iframe est bloquee (Safari ITP, Brave Shields), bascule sur le refresh token explicite avec userManager.signinSilent() qui utilise le refresh token stocke. Le refresh token Zitadel a une duree de vie de 30 jours par defaut, configurable dans Settings > Token Lifetime.

Etape 8 : durcir la prod (CSP, cookies, rate limit)

En production, ajoutez une Content Security Policy stricte qui autorise uniquement votre instance Zitadel comme connect-src et frame-src. Cela empeche les attaques de type token leak via iframe malveillante.

Content-Security-Policy: default-src 'self';
  connect-src 'self' https://VOTRE-INSTANCE.zitadel.cloud;
  frame-src https://VOTRE-INSTANCE.zitadel.cloud;
  script-src 'self' 'unsafe-inline';

Si vous hebergez Zitadel en self-hosted sur un VPS a Dakar (ex. Hostafrica, ARC Informatique, Orange Business), placez-le derriere Cloudflare avec WAF active et rate limit a 100 requetes/minute par IP sur les endpoints /oauth/v2/token et /oauth/v2/authorize. Cela bloque le credential stuffing sans gener les utilisateurs legitimes.

Etape 9 : tester de bout en bout avec un compte de demo

Cree un utilisateur de test dans Zitadel (Users > New). Donnez-lui le role minimum dans le projet. Lance le frontend, clique Login, complete le flux. Verifie dans DevTools > Application > Local Storage qu’une cle oidc.user:... contient un access_token, un id_token et un refresh_token. Decode l’id_token sur jwt.io (toujours verifier sa signature en plus du decodage). Le claim aud doit contenir l’ID projet, le claim iss doit etre l’URL exacte de l’issuer.

Pour approfondir, lisez aussi Keycloak SAML vs OIDC et JWT : bonnes pratiques 2026 pour comparer les approches et durcir le stockage des tokens.

Etape 10 : monitorer les sessions actives et detecter les anomalies

Une fois en production, on a besoin de visibilite : combien d’utilisateurs sont connectes, quels appareils, depuis quels pays. Zitadel expose une API d’administration qui liste les sessions actives. Cree un service-utilisateur (Personal Access Token) avec le role IAM_OWNER_VIEWER, et appelle l’endpoint /admin/v1/users/sessions/_search.

curl -X POST https://VOTRE-INSTANCE.zitadel.cloud/admin/v1/users/sessions/_search \
  -H "Authorization: Bearer $ZITADEL_PAT" \
  -H "Content-Type: application/json" \
  -d '{"query":{"limit":100,"asc":false}}'

La reponse contient userId, userAgent, ipAddress et expirationDate. Pour une equipe basee a Dakar, filtrer les IPs qui ne sont pas en Afrique de l’Ouest peut etre un signal interessant : un compte qui se connecte habituellement depuis Senegal Telecom puis depuis une IP residentielle europeenne en pleine nuit merite une revocation. Pousse ces evenements vers un canal Slack ou Discord d’equipe pour reaction en moins de 15 minutes.

Etape 11 : multi-tenant et organisations en self-hosted

Zitadel supporte nativement le multi-tenant via les Organizations. Une instance peut heberger plusieurs organisations totalement isolees, chacune avec ses propres utilisateurs, projets et politiques de mot de passe. Si vous developpez un SaaS facture en FCFA (par ex. 12 000 FCFA/mois soit environ 18,30 EUR au taux de change fixe 1 EUR = 655,957 FCFA), chaque client devient une organisation, et l’isolation est garantie par Zitadel sans code custom.

Pour creer une organisation programmatiquement (utile a l’inscription d’un nouveau client) :

curl -X POST https://VOTRE-INSTANCE.zitadel.cloud/management/v1/orgs \
  -H "Authorization: Bearer $ZITADEL_PAT" \
  -H "Content-Type: application/json" \
  -d '{"name":"Client SARL Dakar"}'

L’API renvoie un orgId que vous stockez dans votre table tenants. Lors du login, le claim urn:zitadel:iam:org:id dans l’id_token vous indique a quelle organisation l’utilisateur appartient — le backend l’utilise pour scoper toutes les requetes SQL.

Etape 12 : sauvegarder la configuration et passer en CI

Toute la config Zitadel (projets, apps, roles) doit etre versionnee. Zitadel propose une CLI zitadel-tools qui exporte la config en YAML. Stocker ce YAML dans un repo Git prive permet de recreer une instance en moins de 30 minutes en cas de perte. Appliquer le YAML via la pipeline CI (GitHub Actions, GitLab CI) garantit que dev, staging et prod restent alignes — fini les « ca marche en dev mais pas en prod ».

Le signal de reussite final : un utilisateur cree dans Zitadel staging, importe via export/import en prod, peut se connecter immediatement avec son mot de passe d’origine. Si oui, vous avez une infra OIDC reproductible et resiliente.

Etape 13 : checklist finale avant la mise en production

Avant d’ouvrir l’authentification au public, passer cette checklist evite 90 % des incidents post-lancement. Verifie que le mode Development est desactive sur toutes les apps, que les redirect URIs ne contiennent plus aucun localhost, que la duree de vie de l’access token est inferieure ou egale a 60 minutes, que le refresh token rotation est active dans Settings > Token Lifetime, et que la politique de mot de passe impose au moins 12 caracteres et un caractere special.

Active aussi la 2FA obligatoire pour les roles administrateurs : un compte admin compromis donne acces a tous les utilisateurs de l’organisation. Zitadel supporte TOTP, U2F (cles physiques YubiKey, SoloKey) et Passkey. Pour une equipe au Senegal ou en Cote d’Ivoire ou les YubiKey sont rares, TOTP via Google Authenticator ou Aegis suffit largement et coute zero franc.

Dernier signal de reussite : execute un audit avec curl -s https://VOTRE-INSTANCE.zitadel.cloud/.well-known/openid-configuration | jq et confirme que les endpoints, les algorithmes de signature (RS256) et les scopes supportes correspondent a ce que ton code attend. Si tout matche, tu peux annoncer la mise en production.

Partager