🛠️ Module 4 — Mise en pratique en Python
Bonne lecture et bon apprentissage ! Junior TSAFACK – 12/09/2026 ⏱️ Temps de lecture estimé : 12 minutes
Assez de théorie ! 🧪 Nous allons construire un véritable serveur d’autorisation OAuth 2.0 avec Flask, puis jouer le rôle du navigateur et du Client pour exécuter le flux complet — tout le code de ce module a été réellement exécuté (environnement Anaconda dédié), les sorties affichées sont authentiques.
conda create -n oauth-course python=3.11conda activate oauth-coursepip install flask requests🏗️ Le serveur d’autorisation
Section titled “🏗️ Le serveur d’autorisation”Un mini serveur d’autorisation avec 3 endpoints, exactement ceux vus au Module 3 : /authorize, /token, et un /userinfo qui joue le rôle de Resource Server.
import base64import hashlibimport secretsimport timefrom flask import Flask, jsonify, redirect, request
app = Flask(__name__)
REGISTERED_CLIENTS = {"demo-client": "s3cr3t-partage-uniquement-serveur-a-serveur"}AUTH_CODES = {} # code -> {client_id, expires_at, code_challenge}ACCESS_TOKENS = {} # token -> {client_id}
@app.route("/authorize")def authorize(): client_id = request.args.get("client_id") redirect_uri = request.args.get("redirect_uri") state = request.args.get("state") code_challenge = request.args.get("code_challenge")
if client_id not in REGISTERED_CLIENTS: return jsonify({"error": "invalid_client"}), 400
# Dans une vraie appli, l'utilisateur verrait ici un ecran de consentement. code = secrets.token_urlsafe(16) AUTH_CODES[code] = { "client_id": client_id, "expires_at": time.time() + 60, "code_challenge": code_challenge, } return redirect(f"{redirect_uri}?code={code}&state={state}")
@app.route("/token", methods=["POST"])def token(): data = request.form code = data.get("code") client_id = data.get("client_id") client_secret = data.get("client_secret") code_verifier = data.get("code_verifier")
if REGISTERED_CLIENTS.get(client_id) != client_secret: return jsonify({"error": "invalid_client"}), 401
entry = AUTH_CODES.get(code) if not entry or entry["client_id"] != client_id: return jsonify({"error": "invalid_grant", "error_description": "code inconnu"}), 400 if time.time() > entry["expires_at"]: return jsonify({"error": "invalid_grant", "error_description": "code expire"}), 400
# Verification PKCE if entry["code_challenge"]: computed = base64.urlsafe_b64encode( hashlib.sha256((code_verifier or "").encode()).digest() ).rstrip(b"=").decode() if computed != entry["code_challenge"]: return jsonify({"error": "invalid_grant", "error_description": "PKCE verifier invalide"}), 400
del AUTH_CODES[code] # usage unique access_token = secrets.token_urlsafe(24) ACCESS_TOKENS[access_token] = {"client_id": client_id} return jsonify({"access_token": access_token, "token_type": "Bearer", "expires_in": 3600})
@app.route("/userinfo")def userinfo(): auth_header = request.headers.get("Authorization", "") if not auth_header.startswith("Bearer "): return jsonify({"error": "invalid_token"}), 401 access_token = auth_header.removeprefix("Bearer ") if access_token not in ACCESS_TOKENS: return jsonify({"error": "invalid_token"}), 401🎬 Le flux complet, joué par un vrai client
Section titled “🎬 Le flux complet, joué par un vrai client”Le serveur Flask tourne dans un thread ; le reste du script joue tour à tour le rôle du navigateur puis du Client backend.
import threadingfrom urllib.parse import parse_qs, urlparseimport requests
threading.Thread(target=lambda: app.run(port=5001), daemon=True).start()time.sleep(1)
BASE = "http://localhost:5001"CLIENT_ID = "demo-client"CLIENT_SECRET = "s3cr3t-partage-uniquement-serveur-a-serveur"REDIRECT_URI = "http://localhost:9999/callback" # n'existe pas : on lit juste l'URL de redirection
# --- PKCE : genere AVANT le premier appel ---code_verifier = secrets.token_urlsafe(48)code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).rstrip(b"=").decode()print(f"code_verifier (garde secret) : {code_verifier}")print(f"code_challenge (envoye en clair) : {code_challenge}")
# --- ETAPE 1 : le navigateur est redirige vers /authorize ---resp = requests.get(f"{BASE}/authorize", params={ "client_id": CLIENT_ID, "redirect_uri": REDIRECT_URI, "state": "xyz123", "response_type": "code", "code_challenge": code_challenge, "code_challenge_method": "S256",}, allow_redirects=False)print(f"GET /authorize -> HTTP {resp.status_code}")print(f"Location: {resp.headers['Location']}")
query = parse_qs(urlparse(resp.headers["Location"]).query)auth_code, state = query["code"][0], query["state"][0]
# --- ETAPE 2 : le CLIENT (backend) echange le code ---resp = requests.post(f"{BASE}/token", data={ "grant_type": "authorization_code", "code": auth_code, "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "code_verifier": code_verifier,})print(f"POST /token -> HTTP {resp.status_code}")print(resp.json())access_token = resp.json()["access_token"]
# --- ETAPE 3 : le CLIENT lit le profil ---resp = requests.get(f"{BASE}/userinfo", headers={"Authorization": f"Bearer {access_token}"})print(f"GET /userinfo -> HTTP {resp.status_code}")print(resp.json())Sortie réellement obtenue :
code_verifier (garde secret par le client) : dqDeL6an5EHJY82EtaZp3a5PqYcMknRf5pNK9rLCIWGL5nwg5PxmWw_gkvBV0DWecode_challenge (envoye en clair a /authorize) : VkVNlNnjznyt9lNZ5WsDjCljMdbkDnDdLFq7-Ciqdvs
GET /authorize -> HTTP 302Location: http://localhost:9999/callback?code=Bd-T2CSFQC35hpZhvuSikg&state=xyz123
POST /token -> HTTP 200{'access_token': 'fvSS365Hvoun5aEw8GzJ6-b4EkgbSs1T', 'expires_in': 3600, 'token_type': 'Bearer'}
GET /userinfo -> HTTP 200{'email': '[email protected]', 'name': 'Junior TSAFACK', 'sub': 'user-42'}🎉 Le flux complet fonctionne, de bout en bout, avec un vrai serveur HTTP qui répond à de vraies requêtes.
🚫 Provoquons chaque cas d’erreur
Section titled “🚫 Provoquons chaque cas d’erreur”Réutiliser un code d’autorisation déjà consommé
Section titled “Réutiliser un code d’autorisation déjà consommé”first = requests.post(f"{BASE}/token", data={...}) # 1er echange, avec le meme "code"second = requests.post(f"{BASE}/token", data={...}) # 2e echange, MEME codeprint(second.status_code, second.json())400 {'error': 'invalid_grant', 'error_description': 'code inconnu'}Le code a été supprimé (del AUTH_CODES[code]) dès le premier échange réussi : un code d’autorisation est à usage unique, exactement comme prévu par la spécification.
Un mauvais client_secret
Section titled “Un mauvais client_secret”401 {'error': 'invalid_client'}PKCE : un code_verifier invalide (le code a été intercepté, mais pas le secret)
Section titled “PKCE : un code_verifier invalide (le code a été intercepté, mais pas le secret)”400 {'error': 'invalid_grant', 'error_description': 'PKCE verifier invalide'}Même en possession du code d’autorisation intercepté, un attaquant qui ne connaît pas le code_verifier d’origine ne peut pas terminer l’échange — exactement ce que PKCE est conçu pour empêcher (voir Module 3).
Un access token inventé
Section titled “Un access token inventé”401 {'error': 'invalid_token'}🤖 Bonus : le flux Client Credentials (machine-à-machine)
Section titled “🤖 Bonus : le flux Client Credentials (machine-à-machine)”Sans navigateur, sans utilisateur — un service interne s’authentifie directement :
resp = requests.post(f"{BASE}/token", data={ "grant_type": "client_credentials", "client_id": "service-facturation", "client_secret": "s3cr3t-machine-a-machine",})print(resp.status_code, resp.json())200 {'access_token': 'ZQDpQQWSpoCSEFN-ewjMZlBrVgiqyzJc', 'expires_in': 3600, 'token_type': 'Bearer'}Ce token permet ensuite d’appeler directement une API interne, sans jamais impliquer d’utilisateur ni de navigateur — exactement le scénario du Module 2.