← Tous les POCs
POC · mai 2026

Authentification déléguée
de SAML à OIDC.

Synthèse des concepts d'authentification déléguée (OAuth, OIDC, SAML, SSO, Kerberos) avec implémentation concrète d'un "Sign in with Google" en local. Deux niveaux de lecture.

Stack : FastAPI · authlib Provider : Google Flow : Auth Code + PKCE
Bascule le niveau de détail technique

Le problème commun

Sans délégation, chaque site doit gérer mots de passe, hash, reset, MFA, brute-force. Pénible et risqué. La délégation pose un tiers de confiance qui authentifie pour tout le monde.

Trois acteurs reviennent partout, avec deux vocabulaires selon le protocole :

RôleSAMLOIDC / OAuth
L'utilisateurPrincipalEnd-User / Resource Owner
Le site qui veut authentifierSP — Service ProviderRP — Relying Party / Client
Le service qui authentifieIdP — Identity ProviderOP — OpenID Provider
User toi Site (SP/RP) ton app IdP / OP Google, ADFS… visite délègue
Le triangle de l'authentification déléguée

OAuth 2.0 — autorisation

2012, RFC 6749. Pensé pour qu'une app tierce accède à tes données chez un autre service, sans connaître ton mot de passe.

Cas canonique : Trello veut lire ton Google Drive. Trello ne doit jamais voir le mot de passe Google. OAuth résout exactement ça.

Le flow Authorization Code

User Client (Trello) Auth Server (Google) Resource (Drive API) clique "Connect Drive" 1 redirige → Google 2 authentifie + consent 3 redirige avec ?code=XYZ 4 transmet le code 5 code + client_secret → /token 6 access_token 7 GET /files Authorization: Bearer … 8
Diagramme de séquence — Authorization Code Flow
Le point à graver

L'access_token est une clé d'accès opaque. Trello sait qu'il peut appeler Drive pendant 1h. Il ne sait rien de toi. Aucune information d'identité standardisée n'est garantie dans ce token.

Erreur historique : entre 2010 et 2014, beaucoup ont utilisé OAuth pour faire du login en appelant /me après coup. Ça marche mais c'est faux : un access_token prouve un accès, pas une identité émise pour toi. C'est la faille du confused deputy — d'où OIDC.

Clin d'œil cinéma

Pour aller plus loin sur OAuth 2.1 — la conférence de Julien Topçu à Devoxx France 2022, OAUTH 2.1 expliqué simplement (même si tu n'es pas dev), vulgarise le sujet par analogies tirées du film de Wes Anderson, The Grand Budapest Hotel. Cinquante minutes ludiques pour démonter le protocole.

Voir le talk sur YouTube

Miniature YouTube du talk Devoxx France 2022 de Julien Topçu sur OAuth 2.1, avec le titre Grand Budapest Hôtel
La vidéo Devoxx France 2022 — Julien Topçu fait le parallèle entre OAuth 2.1 et les codes d'accès aux chambres du Grand Budapest Hôtel.
Gustave H. et Zero dans le Grand Budapest Hotel de Wes Anderson
M. Gustave et son lobby boy Zero — l'univers Wes Anderson au service de la pédagogie technique.

OIDC — identité par-dessus

2014. OpenID Connect ajoute une seule chose fondamentale à OAuth : un second token émis en parallèle de l'access_token — l'id_token, un JWT signé qui contient l'identité de l'utilisateur de manière vérifiable.

Anatomie d'un id_token

HEADER { alg: "RS256", kid: "abc123" } algo + key id . PAYLOAD (claims) iss: accounts.google.com sub: 110169 (ID stable) aud: ton_client_id exp, nonce, email… l'identité, en clair . SIGNATURE RSA(header.payload, clé privée Google) vérifiée via JWKS chaque partie est base64url, séparées par des points
Structure d'un JSON Web Token

Ce qu'on vérifie côté client

Signature
RSA via clé publique Google (récupérée sur /.well-known/jwks.json)
iss
émetteur attendu — pour Google : https://accounts.google.com (historiquement aussi accounts.google.com sans le schéma ; libs récentes acceptent les deux)
aud
ce token est destiné à ton client_id
exp
pas expiré
nonce
valeur que tu as fournie à l'étape 1, anti-replay

Si tout passe : tu sais avec certitude qui est l'user, émis par qui, pour toi, pas rejoué.

Distinction à retenir

access_token = "voici un droit d'accès à une API" (opaque, pas pour toi).
id_token = "voici l'identité de l'utilisateur, signée, émise pour ton client_id."

SAML — l'ancêtre XML

2005. Même objectif qu'OIDC : authentification déléguée. Mais format XML, transport via redirections + POST de formulaires, conçu pour le web entreprise.

Le flow web SP-initiated

User (navigateur) SP (ton app) IdP (ADFS, Ping…) GET /app redirige + AuthnRequest XML forward de l'AuthnRequest login + assertion XML signée POST /saml/acs (assertion) SP vérifie signature XML, ouvre session
SAML 2.0 — HTTP POST binding

L'assertion SAML est auto-suffisante : identité + attributs (email, groupes, rôles) + validité, le tout dans un seul XML signé. Confiance via PKI : SP et IdP échangent leurs certificats à l'enrôlement (les metadata).

SAML vs OIDC en un tableau

CritèreSAML 2.0OIDC
Année20052014
FormatXMLJSON / JWT
MétadonnéesXML statique échangéDiscovery dynamique
Mobile / SPAPénibleNatif (PKCE)
VerbositéTrèsCompact
ÉcosystèmeSSO entreprise B2BCloud, SaaS, consumer

SSO — propriété, pas protocole

Le Single Sign-On n'est pas un protocole. C'est une propriété qu'on obtient en utilisant un protocole d'authentification déléguée (SAML ou OIDC) avec un IdP central partagé par plusieurs apps.

Image mentale

SAML, OIDC = le comment. SSO = le résultat. Tu ne "fais pas du SSO" — tu déploies SAML ou OIDC d'une manière qui produit du SSO.

Le mécanisme

Toutes les apps délèguent au même IdP. Une fois que tu as une session active chez l'IdP, toutes les apps qui parlent à cet IdP profitent de cette session — la magie tient entièrement dans le cookie posé sur le domaine de l'IdP.

IdP cookie de session ici = clé du SSO App 1 App 2 App 3 chaque app a sa session locale, toutes délèguent au même IdP
Architecture SSO en hub-and-spoke

SSO vs Federation

SSO : un IdP, plusieurs apps dans le même périmètre (typiquement une entreprise).
Federation : plusieurs IdP se font confiance mutuellement (ex. eduGAIN entre universités).

Kerberos & IWA — le SSO Windows

Sur poste joint à Active Directory, tu te connectes le matin et toutes les apps internes te reconnaissent ensuite sans mot de passe. C'est Kerberos qui rend ça possible, et IWA qui le branche au web.

Le flow complet

Poste Windows login matin KDC (AD) contrôleur de domaine mot de passe TGT (10h) ─ plus tard dans la journée ─ Navigateur TGT en mémoire KDC IdP (ADFS) pont Kerberos→SAML demande ST ST pour HTTP/adfs HTTPS + Authorization: Negotiate <ST> (IWA) App SaaS / interne assertion SAML redirection l'user n'a tapé qu'un mot de passe — celui du matin
Du login Windows à l'assertion SAML
Vocabulaire à retenir

TGT (Ticket-Granting Ticket) — passeport global obtenu au login, valable ~10h, stocké en mémoire.
ST (Service Ticket) — ticket ponctuel pour un service donné, fabriqué à la demande à partir du TGT.
IWA (Integrated Windows Authentication) — la "plomberie" HTTP (header WWW-Authenticate: Negotiate, RFC 4559) qui transporte le ST entre navigateur et serveur.

Points pratiques

Quand on dit "le SSO ne marche plus", c'est presque toujours Kerberos qui souffre (TGT expiré, SPN mal configuré, DNS cassé), pas SAML. Et les nouveaux agents IA en service-account ne peuvent pas faire de Kerberos comme un humain — friction implicite à arbitrer.

Règle de décision

ContexteProtocole
App web/mobile moderne, login consumerOIDC
SSO entreprise, intégration SI legacySAML (souvent imposé)
API accédant aux données d'un user chez un autre serviceOAuth 2.0 (sans OIDC)
Plusieurs apps internes partageant un IdPSAML ou OIDC → SSO
Poste Windows joint AD + IdP fédérateurKerberos + IWA en amont
Pour le POC qui suit

On implémente OIDC avec Google, flow Authorization Code + PKCE. C'est le pattern moderne le plus strict. Tout ce qu'on écrit ici se transpose à n'importe quel OP (Microsoft, GitHub, Apple, Keycloak, Okta…) — seuls les endpoints changent.

Le POC OIDC en pratique

Maintenant qu'on a le paysage en tête, voici l'implémentation concrète d'un "Sign in with Google" en local. Le flow utilisé est Authorization Code + PKCE, recommandé aujourd'hui pour toute nouvelle intégration OIDC.

Le flow, étape par étape

User Notre app Google clique "Sign in" 1 redirige → Google 2 authentifie + consentement 3 redirige avec code temporaire 4 transmet le code 5 échange code → id_token (en privé) 6 id_token signé 7
Diagramme de séquence — Authorization Code + PKCE

Le POC en action

Le flow vu côté utilisateur, en trois écrans. Du clic à la session active : aucune saisie de mot de passe sur notre app, tout passe par Google.

Page d'accueil avec bouton Sign in with Google
01La page d'accueil — un bouton, et c'est tout. Pas de formulaire mot de passe.
Écran de connexion Google avec mention oidc-poc-local
02L'écran Google — l'authentification se déroule chez Google, qui mentionne explicitement l'app appelante oidc-poc-local.
Dashboard final avec nom et avatar de l'utilisateur connecté
03Le dashboard — session active. Nom, email et avatar viennent du id_token signé par Google.

Le vocabulaire qui revient dans le code

Une demi-douzaine de termes traversent toutes les implémentations OIDC. Les comprendre, c'est lire le code sans buter.

state "le ticket de vestiaire"
Un nombre aléatoire que l'app génère au début et compare à la fin. Si Google renvoie un state différent, c'est qu'on n'est pas dans un flow légitime. Protection CSRF — empêche qu'un attaquant fasse atterrir la victime sur /auth/callback avec un code volé.
nonce "valable une seule fois"
De l'anglais number used once. Inclus à l'intérieur du token signé par Google. L'app vérifie qu'il correspond bien à celui envoyé au départ. Protection anti-replay — si un attaquant rejoue un ancien id_token, le nonce ne correspondra plus.
PKCE "pixie"
Proof Key for Code Exchange. L'app génère un secret (verifier), envoie son hash (challenge) à Google, et révèle le verifier seulement à l'échange. Protège contre l'interception du code dans le navigateur.
JWKS "djay-double-uss"
JSON Web Key Set. L'annuaire des clés publiques de Google. L'app le télécharge pour vérifier la signature du id_token. Plusieurs clés en rotation — le JWT pointe vers la bonne via le champ "kid" dans son header.
at_hash
Hash de l'access_token, parfois inclus dans le id_token. Quand il est présent, il prouve que les deux tokens ont été émis ensemble et n'ont pas été substitués en chemin. Obligatoire dans certains flows OIDC (hybrid), optionnel dans Authorization Code. Google le met par défaut, d'autres providers non. La règle : si présent, le vérifier ; absent, ne pas le réclamer.
scope
La liste de ce qu'on demande à Google. openid obligatoire pour activer OIDC, email profile pour les infos de base.

Deux versions du même code

Le POC existe en deux versions : une "tout-en-main" qui détaille chaque étape, et une "production" qui délègue à la librairie authlib.

À la main

240 lignes

On code chaque étape : génération PKCE, anti-CSRF, échange du code, vérification de la signature, validation des claims, comparaison du nonce, gestion du cookie temporaire.

Pédagogiquement précieux. Pas pour la production.

Avec authlib

40 lignes

La librairie gère PKCE, state, nonce, JWKS, signature, claims, session. On se concentre sur la logique métier.

Version pratique — ce qu'on déploie.

Le code complet — version production (authlib)

La version qu'on déploierait vraiment. Trois routes principales (/auth/login, /auth/callback, /auth/me) et une configuration en haut.

server.py — version production · ~40 lignes utiles
"""
═══════════════════════════════════════════════════════════════════════════════
POC OIDC avec Google — version simple via authlib
═══════════════════════════════════════════════════════════════════════════════

Cette version délègue toute la mécanique OIDC à la librairie `authlib`.
Le code est court (~40 lignes utiles) et fait exactement la même chose
que la version "à la main" : login Google → session → dashboard.

Ce qu'authlib gère automatiquement (et qu'on faisait manuellement avant) :

  - Découverte des URLs de Google (auth, token, JWKS) via le document
    /.well-known/openid-configuration
  - Génération du code_verifier et code_challenge (PKCE)
  - Génération du state (anti-CSRF) et du nonce (anti-replay)
  - Stockage temporaire de ces valeurs entre /login et /callback
  - Échange du code d'autorisation contre les tokens
  - Récupération et cache des clés publiques de Google (JWKS)
  - Vérification de la signature du id_token
  - Vérification des claims (iss, aud, exp, at_hash, nonce)
  - Extraction des informations utilisateur (sub, email, name, picture)

Bref : tout ce qu'on avait écrit explicitement dans server.py, authlib
le fait sous le capot. C'est la version à utiliser en pratique.
"""

# ─── Imports ─────────────────────────────────────────────────────────────────
import os

# authlib : librairie qui implémente OAuth 2.0 et OpenID Connect proprement.
# L'intégration "starlette_client" fonctionne avec FastAPI (qui s'appuie
# sur Starlette en interne).
from authlib.integrations.starlette_client import OAuth
from authlib.integrations.base_client.errors import OAuthError

# FastAPI : le framework web qui gère les routes (/, /auth/login, etc.)
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse, JSONResponse, FileResponse

# SessionMiddleware : ajoute un système de session (cookie signé) à FastAPI.
# C'est lui qui permet de faire `request.session["user"] = ...` plus bas.
from starlette.middleware.sessions import SessionMiddleware

# dotenv : charge les variables du fichier .env dans l'environnement.
from dotenv import load_dotenv


# ─── Chargement de la configuration ──────────────────────────────────────────
# Lit le fichier .env et place ses variables dans os.environ.
# Doit être appelé AVANT de lire ces variables ci-dessous.
load_dotenv()


# ─── Initialisation de l'application ─────────────────────────────────────────
app = FastAPI()

# On branche le middleware de session sur l'app.
# Ça pose un cookie signé sur le navigateur de l'utilisateur, dans lequel
# on pourra stocker des choses comme "user" via request.session.
# La clé SESSION_SECRET sert à signer ce cookie pour qu'il ne puisse pas
# être modifié côté navigateur.
app.add_middleware(
    SessionMiddleware,
    secret_key=os.environ["SESSION_SECRET"],
)


# ─── Configuration du client OAuth/OIDC ──────────────────────────────────────
# On crée un objet OAuth qui va contenir tous nos "providers" d'authentification.
# Ici on n'en aura qu'un (google), mais on pourrait en ajouter d'autres
# (github, microsoft, etc.) sans changer le reste du code.
oauth = OAuth()

oauth.register(
    name="google",
    client_id=os.environ["GOOGLE_CLIENT_ID"],
    client_secret=os.environ["GOOGLE_CLIENT_SECRET"],

    # URL de découverte OIDC : authlib va lire ce document pour trouver
    # automatiquement les URLs d'auth, de token, de JWKS, etc. de Google.
    # On n'a plus besoin de les mettre en dur comme avant.
    server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",

    # Scopes demandés à Google :
    #   - openid  : obligatoire pour activer OIDC (sinon on fait juste de l'OAuth)
    #   - email   : permet à Google de nous donner l'email de l'utilisateur
    #   - profile : permet d'avoir le nom et la photo
    #
    # code_challenge_method="S256" : active PKCE (Proof Key for Code Exchange).
    # IMPORTANT : sans ce paramètre, authlib ne génère PAS de code_challenge,
    # et on retombe sur du pur Authorization Code Flow (sans PKCE).
    # Avec S256, authlib génère un code_verifier, le stocke en session,
    # envoie SHA256(verifier) à Google, puis présente le verifier à l'échange.
    client_kwargs={
        "scope": "openid email profile",
        "code_challenge_method": "S256",
    },
)


# ─── Routes statiques (servent simplement des fichiers HTML) ─────────────────
@app.get("/")
def index():
    """Page d'accueil avec le bouton 'Sign in with Google'."""
    return FileResponse("static/index.html")


@app.get("/dashboard")
def dashboard():
    """
    Page protégée affichée après la connexion.

    ATTENTION : ce endpoint sert juste le HTML. La page n'est PAS protégée
    côté serveur — n'importe qui peut télécharger ce HTML. Ce sont les
    données métier (renvoyées par /auth/me) qui sont protégées par session.
    En production, les routes qui exposent des données sensibles doivent
    vérifier la session côté serveur avant de répondre.
    """
    return FileResponse("static/dashboard.html")


@app.get("/concepts")
def concepts():
    """Page de synthèse théorique sur OAuth/OIDC/SAML/SSO/Kerberos."""
    return FileResponse("static/concepts.html")


# ─── Démarrage du flow de connexion ──────────────────────────────────────────
@app.get("/auth/login")
async def login(request: Request):
    """
    Étape 1 : l'utilisateur a cliqué sur 'Sign in with Google'.

    authorize_redirect() fait tout le travail :
      - génère state, nonce, code_verifier
      - stocke ces valeurs dans la session pour les retrouver au callback
      - construit l'URL d'autorisation de Google avec tous les paramètres
      - renvoie une redirection vers cette URL

    L'utilisateur va donc être envoyé sur accounts.google.com pour se connecter.
    """
    return await oauth.google.authorize_redirect(
        request,
        "http://localhost:8000/auth/callback",
    )


# ─── Retour depuis Google après authentification ─────────────────────────────
@app.get("/auth/callback")
async def callback(request: Request):
    """
    Étape 2 : Google a authentifié l'utilisateur et le renvoie sur notre site.

    authorize_access_token() fait tout le travail :
      - récupère le 'code' que Google a mis dans l'URL
      - vérifie que le 'state' correspond bien à celui qu'on avait généré
      - échange le code contre les tokens (access_token + id_token)
        en présentant le code_verifier PKCE
      - récupère les clés publiques de Google (JWKS)
      - vérifie la signature du id_token et tous ses claims
      - décode le contenu du id_token pour en extraire l'identité

    Le `token` qu'on récupère contient :
      - token["access_token"]  : pour appeler les APIs Google si besoin
      - token["id_token"]      : le JWT brut (utile pour debug)
      - token["userinfo"]      : un dict avec sub, email, name, picture, etc.

    En cas d'erreur OAuth (state invalide, signature mauvaise, etc.),
    authlib lève une exception OAuthError. On l'attrape pour renvoyer
    une 400 propre au lieu d'un 500 cryptique.
    """
    try:
        token = await oauth.google.authorize_access_token(request)
    except OAuthError as e:
        # Erreurs OAuth/OIDC : state invalide, code expiré, signature mauvaise,
        # claims invalides, etc. — on renvoie une 400 lisible.
        return JSONResponse(
            {"error": "oauth_error", "description": str(e)},
            status_code=400,
        )

    # On stocke un sous-ensemble minimal de l'identité dans la session.
    # Pour un POC c'est OK. En production, on stockerait juste le 'sub'
    # (identifiant stable Google) et on rechargerait l'utilisateur depuis
    # une base de données indexée sur ce sub.
    userinfo = token["userinfo"]
    request.session["user"] = {
        "sub": userinfo["sub"],
        "email": userinfo.get("email"),
        "name": userinfo.get("name"),
        "picture": userinfo.get("picture"),
    }

    # Redirection vers le dashboard, qui va afficher l'utilisateur connecté.
    return RedirectResponse("/dashboard")


# ─── Endpoint qui dit qui est l'utilisateur connecté ─────────────────────────
@app.get("/auth/me")
def me(request: Request):
    """
    Le dashboard appelle cet endpoint en AJAX pour récupérer les infos
    de l'utilisateur courant et les afficher.

    Renvoie un JSON :
      - 401 si personne n'est connecté
      - 200 avec les infos sinon
    """
    user = request.session.get("user")
    if not user:
        return JSONResponse({"authenticated": False}, status_code=401)
    return {"authenticated": True, "user": user}


# ─── Déconnexion ─────────────────────────────────────────────────────────────
@app.post("/auth/logout")
def logout(request: Request):
    """
    Vide la session. Au prochain appel à /auth/me, l'utilisateur sera
    considéré comme déconnecté.

    Note : cela ne déconnecte PAS l'utilisateur de Google. Il reste
    connecté côté Google et pourra se reconnecter à notre app sans
    retaper son mot de passe (c'est le principe du SSO).
    """
    request.session.clear()
    return JSONResponse({"ok": True})


# ─── Démarrage du serveur ────────────────────────────────────────────────────
if __name__ == "__main__":
    import uvicorn

    # host="::" : on écoute en IPv6 ET IPv4 (dual-stack).
    # Important sous Linux moderne où `localhost` résout d'abord en IPv6.
    uvicorn.run(app, host="::", port=8000)

Le code complet — version "à la main"

La version sans lib, plus longue, qui montre tout ce qu'authlib fait sous le capot. Utile pour comprendre ou pour débugger.

server.py — version manuelle · ~230 lignes
"""
═══════════════════════════════════════════════════════════════════════════════
POC OIDC avec Google — flow Authorization Code + PKCE, à la main.
═══════════════════════════════════════════════════════════════════════════════

Aucune librairie OIDC de haut niveau : on écrit chaque étape pour
comprendre exactement ce qui se passe sur le fil.

Vue d'ensemble du flow :

    1. User clique "Sign in with Google" → GET /auth/login
       → On génère state + nonce + PKCE
       → On redirige vers accounts.google.com avec ces paramètres

    2. Google authentifie l'user, lui demande son consentement
       → Google redirige vers GET /auth/callback?code=...&state=...

    3. On vérifie state (CSRF), on échange le code contre des tokens,
       on vérifie le id_token (signature + claims), on crée la session.

    4. L'user a un cookie de session signé → /dashboard l'identifie via /auth/me

Endpoints :
    GET  /                  → page d'accueil (HTML statique)
    GET  /auth/login        → redirige vers Google
    GET  /auth/callback     → reçoit le code, échange, crée session
    GET  /auth/me           → renvoie l'utilisateur courant en JSON
    POST /auth/logout       → détruit la session
    GET  /dashboard         → page protégée (HTML statique)
    GET  /concepts          → page de synthèse théorique (HTML statique)
"""

# ─── Imports ─────────────────────────────────────────────────────────────────
import os
import secrets  # génération de tokens cryptographiquement aléatoires
import hashlib  # SHA256 pour PKCE
import base64  # encodage base64url
from urllib.parse import urlencode  # construire les query strings d'URL

import httpx  # client HTTP async (pour appeler Google)
from fastapi import FastAPI, Request, HTTPException, Response
from fastapi.responses import RedirectResponse, JSONResponse, FileResponse
from itsdangerous import URLSafeSerializer, BadSignature  # signer les cookies
from jose import jwt  # décoder et vérifier les JWT (id_token)
from dotenv import load_dotenv  # charger les variables d'environnement depuis .env

# Charge les variables définies dans le fichier .env dans os.environ.
# Doit être appelé AVANT de lire os.environ ci-dessous.
load_dotenv()


# ─── Configuration ───────────────────────────────────────────────────────────
# On lit les secrets dans l'environnement (jamais en dur dans le code).
# Si une variable est manquante, le programme plante au démarrage : c'est
# voulu, on veut détecter tout de suite une mauvaise configuration.
CLIENT_ID = os.environ["GOOGLE_CLIENT_ID"]
CLIENT_SECRET = os.environ["GOOGLE_CLIENT_SECRET"]
SESSION_SECRET = os.environ["SESSION_SECRET"]

# URL exacte vers laquelle Google va rediriger après authentification.
# DOIT correspondre à la lettre à celle déclarée dans la console Google.
REDIRECT_URI = "http://localhost:8000/auth/callback"

# Endpoints de Google.
# En production, on les découvre dynamiquement via le document de discovery :
# https://accounts.google.com/.well-known/openid-configuration
# Ici on les met en dur pour rester pédagogique.
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs"
GOOGLE_ISSUER = "https://accounts.google.com"

# Instance de l'application FastAPI.
app = FastAPI()

# Sérialiseurs pour signer (mais pas chiffrer) les cookies.
# On en utilise DEUX, avec des `salt` distincts, pour séparer logiquement
# les usages : le cookie de session ne doit pas pouvoir être interchangé
# avec le cookie temporaire `oauth_tmp` posé entre /login et /callback.
# Même clé secrète sous-jacente (SESSION_SECRET) mais signatures dérivées
# différentes grâce aux salts.
session_serializer = URLSafeSerializer(SESSION_SECRET, salt="session")
oauth_tmp_serializer = URLSafeSerializer(SESSION_SECRET, salt="oauth-tmp")

# Cache mémoire pour les clés publiques de Google (JWKS).
# En production il faudrait un TTL et un refresh périodique : Google peut
# faire tourner ses clés à tout moment.
_jwks_cache = None


# ─── Helpers PKCE ────────────────────────────────────────────────────────────
def gen_pkce_pair():
    """
    Génère une paire (code_verifier, code_challenge) pour PKCE (RFC 7636).

    PKCE protège contre l'interception du code d'autorisation : même si
    quelqu'un récupère le `code` dans l'URL de redirection, il ne pourra
    pas l'échanger contre un token sans le `code_verifier` original.

    - code_verifier  : chaîne aléatoire de 43 à 128 caractères, on la garde
                       côté serveur dans un cookie temporaire.
    - code_challenge : SHA256(verifier), encodé en base64url sans padding.
                       C'est ce qu'on envoie à Google dans l'URL d'auth.

    Plus tard, on enverra le verifier à Google lors de l'échange du code,
    et Google vérifiera que SHA256(verifier) == challenge reçu initialement.
    """
    verifier = secrets.token_urlsafe(64)
    challenge = (
        base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
        .rstrip(b"=")
        .decode()
    )
    return verifier, challenge


# ─── Helpers session ─────────────────────────────────────────────────────────
def set_session(response: Response, data: dict):
    """
    Pose un cookie de session signé sur la réponse.

    Le cookie est signé (pas chiffré) : son contenu est lisible par le
    navigateur, mais ne peut pas être modifié sans la clé SESSION_SECRET.
    → Donc on n'y met JAMAIS de secret, juste des données d'identification
      (sub, email, name…).

    Options du cookie :
      - httponly=True  : le cookie n'est pas accessible en JavaScript
                         (protection XSS — un attaquant qui injecte du JS
                         ne peut pas voler la session).
      - samesite="lax" : le cookie n'est pas envoyé sur les requêtes
                         cross-site sauf navigation top-level (protection
                         CSRF de base).
      - max_age=3600   : valable 1h.
      - secure=True    : à ajouter en prod (cookie envoyé uniquement en
                         HTTPS). En local sur http://localhost, on l'omet.
    """
    response.set_cookie(
        key="session",
        value=session_serializer.dumps(data),
        httponly=True,
        samesite="lax",
        max_age=3600,
        # secure=True,  # à activer en production (HTTPS only)
    )


def get_session(request: Request) -> dict | None:
    """
    Récupère et vérifie le cookie de session.

    Retourne le dict d'origine si le cookie est présent ET sa signature
    est valide. Retourne None sinon (pas de cookie, ou cookie trafiqué).
    """
    raw = request.cookies.get("session")
    if not raw:
        return None
    try:
        return session_serializer.loads(raw)
    except BadSignature:
        # Le cookie a été modifié ou signé avec une autre clé :
        # on l'ignore comme s'il n'existait pas.
        return None


# ─── Routes statiques ────────────────────────────────────────────────────────
@app.get("/")
def index():
    """Page d'accueil avec le bouton 'Sign in with Google'."""
    return FileResponse("static/index.html")


@app.get("/dashboard")
def dashboard():
    """
    Page protégée. Pas de vérification d'auth côté serveur ici : la page
    fait elle-même un fetch sur /auth/me et redirige si 401. C'est un
    choix pédagogique pour bien séparer les responsabilités.

    ATTENTION : le HTML lui-même est public — n'importe qui peut le
    télécharger. Ce qui est protégé, c'est uniquement l'endpoint
    /auth/me qui renvoie les données utilisateur. En production, les
    routes qui exposent des données sensibles doivent vérifier la
    session côté serveur AVANT de répondre, pas après côté client.
    """
    return FileResponse("static/dashboard.html")


@app.get("/concepts")
def concepts():
    """Page de synthèse théorique sur OAuth/OIDC/SAML/SSO/Kerberos."""
    return FileResponse("static/concepts.html")


# ─── Route : démarrage du flow d'authentification ────────────────────────────
@app.get("/auth/login")
def login(response: Response):
    """
    Étape 1 du flow OIDC : on prépare et on redirige vers Google.

    On génère trois valeurs aléatoires :

      - state         : valeur anti-CSRF qu'on va comparer au retour.
                        Si l'attaquant nous redirige vers /auth/callback
                        avec son propre code, son state ne matchera pas
                        le nôtre → on rejette.

      - nonce         : valeur anti-replay qui sera incluse dans le
                        id_token. Si un attaquant rejoue un ancien
                        id_token, son nonce ne matchera pas → on rejette.

      - code_verifier : secret PKCE qu'on garde côté serveur, et dont
                        on envoie le hash (challenge) à Google.

    Les trois valeurs sont stockées dans un cookie temporaire signé,
    qu'on relit au callback pour vérifier la cohérence.
    """
    state = secrets.token_urlsafe(32)
    nonce = secrets.token_urlsafe(32)
    code_verifier, code_challenge = gen_pkce_pair()

    # Paramètres de l'URL d'autorisation Google.
    # Référence : https://developers.google.com/identity/openid-connect/openid-connect
    params = {
        "client_id": CLIENT_ID,
        "redirect_uri": REDIRECT_URI,
        "response_type": "code",  # on veut un code d'autorisation
        "scope": "openid email profile",  # openid = obligatoire pour OIDC
        "state": state,
        "nonce": nonce,
        "code_challenge": code_challenge,
        "code_challenge_method": "S256",  # SHA256 (alternatif : "plain", à éviter)
        "access_type": "online",  # 'offline' si on veut un refresh_token
        "prompt": "select_account",  # force le sélecteur de compte Google
    }
    auth_url = f"{GOOGLE_AUTH_URL}?{urlencode(params)}"

    # On construit la réponse de redirection.
    resp = RedirectResponse(auth_url)

    # Cookie temporaire pour conserver state/nonce/verifier entre /login et /callback.
    # Durée : 10 min, suffisant pour que l'user se connecte sur Google.
    # On utilise oauth_tmp_serializer (salt="oauth-tmp") pour qu'un cookie
    # `oauth_tmp` ne puisse pas être interchangé avec un cookie `session`.
    resp.set_cookie(
        "oauth_tmp",
        oauth_tmp_serializer.dumps(
            {
                "state": state,
                "nonce": nonce,
                "verifier": code_verifier,
            }
        ),
        httponly=True,
        samesite="lax",
        max_age=600,
    )
    return resp


# ─── Helper : récupération des clés publiques de Google ──────────────────────
async def fetch_jwks(force_refresh: bool = False):
    """
    Récupère les JWKS (JSON Web Key Set) de Google : les clés publiques
    qui servent à vérifier la signature des id_token.

    `force_refresh=True` : ignore le cache et re-télécharge le JWKS.
    Utile quand on rencontre un `kid` inconnu (cas où Google a fait
    tourner ses clés depuis notre dernier fetch).

    En production, on ajouterait aussi un TTL (par exemple 1h) et un
    refresh périodique en tâche de fond. Google fait tourner ses clés
    sans préavis ; si on cache trop longtemps, on rejette des tokens
    valides signés avec une nouvelle clé.
    """
    global _jwks_cache
    if force_refresh or _jwks_cache is None:
        async with httpx.AsyncClient() as client:
            r = await client.get(GOOGLE_JWKS_URL)
            _jwks_cache = r.json()
    return _jwks_cache


def _find_key(jwks: dict, kid: str):
    """Retourne la clé du JWKS qui matche le kid demandé, ou None."""
    return next((k for k in jwks["keys"] if k["kid"] == kid), None)


# ─── Route : callback OAuth/OIDC ─────────────────────────────────────────────
@app.get("/auth/callback")
async def callback(
    request: Request,
    code: str | None = None,
    state: str | None = None,
    error: str | None = None,
):
    """
    Étape 2 du flow OIDC : Google nous redirige ici avec un `code`
    d'autorisation, qu'on va échanger contre des tokens.

    Étapes :
      1. Vérifier qu'on a bien `code` et `state` (et pas une erreur).
      2. Récupérer le cookie temporaire et vérifier le state (anti-CSRF).
      3. POST sur l'endpoint /token de Google avec code + verifier + secret.
      4. Récupérer access_token + id_token.
      5. Vérifier la signature du id_token avec les JWKS de Google.
      6. Vérifier les claims (iss, aud, exp — faits par jwt.decode).
      7. Vérifier le nonce (anti-replay).
      8. Créer la session utilisateur.
    """
    # ─── 1. Gestion des erreurs renvoyées par Google ─────────────────────────
    if error:
        # Exemples : access_denied (user a refusé), invalid_request, etc.
        raise HTTPException(400, f"Erreur Google : {error}")
    if not code or not state:
        raise HTTPException(400, "Paramètres code ou state manquants")

    # ─── 2. Vérification du state (anti-CSRF) ────────────────────────────────
    # On relit le cookie temporaire posé au /login.
    tmp_raw = request.cookies.get("oauth_tmp")
    if not tmp_raw:
        # Pas de cookie : soit l'user n'est pas passé par /login,
        # soit le cookie a expiré (>10 min sur l'écran Google).
        raise HTTPException(400, "Cookie oauth_tmp manquant")
    try:
        tmp = oauth_tmp_serializer.loads(tmp_raw)
    except BadSignature:
        raise HTTPException(400, "Signature du cookie oauth_tmp invalide")

    if tmp["state"] != state:
        # Le state reçu de Google ne correspond pas à celui qu'on avait posé.
        # Probablement une tentative de CSRF : on rejette.
        raise HTTPException(400, "State incohérent (possible CSRF)")

    # ─── 3. Échange du code contre des tokens ────────────────────────────────
    # On POST sur l'endpoint /token de Google. C'est ici que notre
    # client_secret est utilisé — il ne quitte JAMAIS le serveur.
    async with httpx.AsyncClient() as client:
        token_resp = await client.post(
            GOOGLE_TOKEN_URL,
            data={
                "client_id": CLIENT_ID,
                "client_secret": CLIENT_SECRET,
                "code": code,
                "code_verifier": tmp["verifier"],  # le PKCE
                "grant_type": "authorization_code",
                "redirect_uri": REDIRECT_URI,
            },
        )

    if token_resp.status_code != 200:
        # Échec : code expiré, redirect_uri qui ne matche pas, etc.
        raise HTTPException(400, f"Échec de l'échange du code : {token_resp.text}")

    tokens = token_resp.json()
    # `tokens` contient typiquement :
    #   - access_token  : token opaque pour appeler les APIs Google
    #   - expires_in    : durée de validité de l'access_token (~3600s)
    #   - id_token      : JWT signé contenant l'identité de l'user
    #   - scope         : scopes effectivement accordés
    #   - token_type    : "Bearer"

    # ─── 4. Récupération et vérification du id_token ─────────────────────────
    id_token = tokens["id_token"]

    # On récupère les clés publiques de Google.
    jwks = await fetch_jwks()

    # On lit le header du JWT (sans vérifier la signature) pour savoir
    # quelle clé (kid) il faut utiliser pour la vérification.
    # ATTENTION : on lit le header pour récupérer le `kid` uniquement.
    # On NE LIT PAS l'algorithme dans le header pour le passer à
    # jwt.decode() — ce serait une faille classique (« alg confusion »).
    # On force l'algorithme attendu à RS256, conforme au discovery
    # document de Google qui publie id_token_signing_alg_values_supported.
    header = jwt.get_unverified_header(id_token)
    key = _find_key(jwks, header["kid"])

    # Si le `kid` est introuvable, Google a probablement fait tourner ses
    # clés depuis notre dernier fetch. On refresh le JWKS une fois et on
    # réessaie avant de rejeter.
    if not key:
        jwks = await fetch_jwks(force_refresh=True)
        key = _find_key(jwks, header["kid"])

    if not key:
        # Même après refresh : la clé qui a signé le token n'existe pas
        # chez Google. Possible attaque ou bug.
        raise HTTPException(400, "Clé de signature introuvable dans le JWKS")

    # ─── 5. Décodage et validation du id_token ───────────────────────────────
    # jwt.decode() fait beaucoup de choses d'un coup :
    #   - vérifie la signature avec la clé publique
    #   - vérifie que l'algorithme correspond à RS256 (on ne fait PAS
    #     confiance au header.alg du token : on force la valeur attendue)
    #   - vérifie que `aud` (audience) == notre CLIENT_ID
    #   - vérifie que `iss` (issuer) == Google
    #   - vérifie que `exp` (expiration) n'est pas dépassée
    #   - vérifie at_hash si access_token fourni (lien id_token ↔ access_token)
    try:
        claims = jwt.decode(
            id_token,
            key,
            algorithms=["RS256"],
            audience=CLIENT_ID,
            issuer=GOOGLE_ISSUER,
            access_token=tokens["access_token"],
        )
    except Exception as e:
        raise HTTPException(400, f"id_token invalid: {e}")

    # ─── 6. Vérification du nonce (anti-replay) ──────────────────────────────
    # jwt.decode() ne vérifie pas le nonce, c'est à nous de le faire.
    if claims.get("nonce") != tmp["nonce"]:
        # Quelqu'un essaie de rejouer un ancien id_token.
        raise HTTPException(400, "Nonce incohérent (possible rejeu de token)")

    # ─── 7. Construction de la session ───────────────────────────────────────
    # Les claims OIDC standards qu'on récupère :
    #   - sub             : identifiant stable et unique de l'user chez Google
    #                       (NE PAS utiliser l'email comme identifiant : il
    #                       peut changer).
    #   - email           : adresse email
    #   - email_verified  : Google a vérifié cette adresse (généralement true)
    #   - name            : nom complet
    #   - picture         : URL de l'avatar
    user = {
        "sub": claims["sub"],
        "email": claims.get("email"),
        "name": claims.get("name"),
        "picture": claims.get("picture"),
    }

    # ─── 8. Redirection vers le dashboard avec le cookie de session ──────────
    resp = RedirectResponse("/dashboard")
    set_session(resp, user)
    # On nettoie le cookie temporaire : il a fait son office.
    resp.delete_cookie("oauth_tmp")
    return resp


# ─── Route : récupération de l'utilisateur courant ───────────────────────────
@app.get("/auth/me")
def me(request: Request):
    """
    Endpoint JSON qui renvoie l'utilisateur connecté, ou 401 si personne.
    Le dashboard l'appelle en AJAX pour s'afficher.
    """
    user = get_session(request)
    if not user:
        return JSONResponse({"authenticated": False}, status_code=401)
    return {"authenticated": True, "user": user}


# ─── Route : déconnexion ─────────────────────────────────────────────────────
@app.post("/auth/logout")
def logout():
    """
    Détruit la session locale (supprime le cookie).

    Note : ça ne déconnecte PAS l'user de Google. Si on voulait un
    'single logout' complet, il faudrait aussi rediriger vers l'endpoint
    de déconnexion Google — ce qui en pratique n'est presque jamais
    fait (l'user veut souvent rester connecté à Google).
    """
    resp = JSONResponse({"ok": True})
    resp.delete_cookie("session")
    return resp


# ─── Démarrage du serveur en standalone ──────────────────────────────────────
if __name__ == "__main__":
    import uvicorn

    # host="::" : écoute en IPv6 ET IPv4 (dual-stack).
    # Important sous Linux moderne où `localhost` résout d'abord en IPv6.
    uvicorn.run(app, host="::", port=8000)
L'écart entre les deux

Tout ce que la version "à la main" écrit en clair, authlib le fait derrière. Ça compresse 240 lignes en 40. La version manuelle reste précieuse pour comprendre — ou pour débugger quand quelque chose coince et qu'on a besoin de savoir exactement où.

Pièges rencontrés en construisant le POC

1. localhost en IPv6. Sous Linux moderne, localhost résout d'abord en IPv6 (::1). Si le serveur uvicorn écoute en IPv4 seulement (127.0.0.1), les navigateurs perdent les cookies pendant le fallback. Solution : uvicorn.run(app, host="::", port=8000) pour écouter en dual-stack.

2. La vérification at_hash. Google inclut un claim at_hash dans le id_token (hash de l'access_token). Les libs JWT strictes comme python-jose exigent qu'on leur passe l'access_token quand ce claim est présent. L'oublier fait échouer la validation avec un message peu explicite. À noter : at_hash n'est pas universellement présent en OIDC — c'est une bonne pratique côté Google, pas une obligation de la spec dans le flow Authorization Code.

3. Le SSO silencieux. Si l'user est déjà connecté à Google ailleurs, Google fait le retour vers /auth/callback sans afficher d'écran de consentement. C'est le SSO à l'œuvre. Pour forcer le sélecteur de compte, ajouter prompt=select_account dans l'URL d'auth.

4. PKCE avec authlib n'est pas automatique. Contrairement à ce qu'on pourrait croire, authlib ne génère pas de code_challenge par défaut sur un client OIDC starlette : il faut passer explicitement code_challenge_method="S256" dans client_kwargs au oauth.register(). Sans ça, on retombe sur du pur Authorization Code Flow, sans PKCE.

5. L'algorithme de signature dans le header JWT n'est pas fiable. Dans la version "à la main", lire header["alg"] et le passer à jwt.decode(algorithms=[...]) est une faille classique (« alg confusion ») : un attaquant pourrait fournir un token avec un algo faible. La règle : forcer l'algorithme attendu en dur (algorithms=["RS256"] pour Google) ou le lire dans le discovery document, jamais dans le token lui-même.

6. /dashboard HTML est public. Dans ce POC, seul l'endpoint /auth/me est protégé par session. Le HTML /dashboard, lui, est servi à tout le monde. C'est OK pour une démo qui charge ses données en AJAX, mais en production toute route exposant des données sensibles doit vérifier la session côté serveur AVANT de répondre.

Ce qu'il reste à faire pour la prod

Pour aller plus loin : persister les users dans une base (indexée sur le sub, pas l'email), gérer les refresh tokens (pour Google : ajouter access_type=offline et prompt=consent à l'URL d'auth ; pour les providers OIDC standards : scope offline_access), ajouter d'autres providers (GitHub, Microsoft), et déployer en serverless (Lambda + API Gateway + Secrets Manager pour les credentials). Le code authlib ci-dessus est presque tel quel déployable avec l'adaptateur mangum.