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.
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ôle | SAML | OIDC / OAuth |
|---|---|---|
| L'utilisateur | Principal | End-User / Resource Owner |
| Le site qui veut authentifier | SP — Service Provider | RP — Relying Party / Client |
| Le service qui authentifie | IdP — Identity Provider | OP — OpenID Provider |
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.
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.
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.
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.
/.well-known/jwks.json)https://accounts.google.com (historiquement aussi accounts.google.com sans le schéma ; libs récentes acceptent les deux)Si tout passe : tu sais avec certitude qui est l'user, émis par qui, pour toi, pas rejoué.
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."
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.
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).
| Critère | SAML 2.0 | OIDC |
|---|---|---|
| Année | 2005 | 2014 |
| Format | XML | JSON / JWT |
| Métadonnées | XML statique échangé | Discovery dynamique |
| Mobile / SPA | Pénible | Natif (PKCE) |
| Verbosité | Très | Compact |
| Écosystème | SSO entreprise B2B | Cloud, SaaS, consumer |
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.
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.
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.
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).
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.
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.
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.
| Contexte | Protocole |
|---|---|
| App web/mobile moderne, login consumer | OIDC |
| SSO entreprise, intégration SI legacy | SAML (souvent imposé) |
| API accédant aux données d'un user chez un autre service | OAuth 2.0 (sans OIDC) |
| Plusieurs apps internes partageant un IdP | SAML ou OIDC → SSO |
| Poste Windows joint AD + IdP fédérateur | Kerberos + IWA en amont |
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.
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 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.
id_token signé par Google.Une demi-douzaine de termes traversent toutes les implémentations OIDC. Les comprendre, c'est lire le code sans buter.
openid obligatoire pour activer OIDC, email profile pour les infos de base.
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.
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.
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.
La version qu'on déploierait vraiment. Trois routes principales (/auth/login, /auth/callback, /auth/me) et une configuration en haut.
""" ═══════════════════════════════════════════════════════════════════════════════ 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)
La version sans lib, plus longue, qui montre tout ce qu'authlib fait sous le capot. Utile pour comprendre ou pour débugger.
""" ═══════════════════════════════════════════════════════════════════════════════ 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)
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ù.
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.
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.