Chapitre 2 — Authentification et autorisation#

L’authentification répond à la question qui êtes-vous ?, l’autorisation à qu’avez-vous le droit de faire ?. Ces deux mécanismes sont distincts mais complémentaires, et leurs implémentations incorrectes constituent la première source de vulnérabilités dans les APIs modernes. Ce chapitre couvre les patterns concrets : des cookies de session aux flux OAuth 2.0 complets.

Sessions et cookies#

Le mécanisme de session est le plus ancien pattern d’authentification pour les applications web. Le serveur crée une session côté serveur, lui attribue un identifiant opaque, et le transmet au client via un cookie.

Le cycle de vie d’une session#

  1. L’utilisateur s’authentifie (POST avec credentials)

  2. Le serveur vérifie les credentials, crée une session en base ou en mémoire partagée (Redis)

  3. Le serveur répond avec Set-Cookie: session_id=<valeur_opaque>; ...

  4. Le client renvoie automatiquement le cookie à chaque requête suivante

  5. Le serveur valide le cookie, retrouve la session, identifie l’utilisateur

Attributs de sécurité des cookies#

Set-Cookie: session_id=4f7b2a9c...; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600

Attribut

Effet

HttpOnly

Le cookie est inaccessible depuis JavaScript — protection contre le vol par XSS

Secure

Transmis uniquement sur HTTPS

SameSite=Strict

Le cookie n’est pas envoyé lors de navigations cross-site — protection CSRF forte

SameSite=Lax

Envoyé pour les navigations top-level (liens), pas pour les requêtes embedded — équilibre courant

SameSite=None; Secure

Cross-site autorisé — uniquement pour des cas légitimes (iframes, OAuth)

Max-Age=N

Durée de vie en secondes

Domain

Si omis, le cookie est limité au host exact

Limitations pour les APIs#

Les cookies de session présentent plusieurs limites importantes pour les APIs modernes :

  • Statefulness : le serveur doit stocker l’état de session (scalabilité horizontale complexe sans session partagée via Redis)

  • CSRF : nécessite une protection explicite (token CSRF ou SameSite)

  • Clients non-navigateurs : les clients mobiles, CLI, et services M2M ne gèrent pas naturellement les cookies

  • Cross-domain : la politique same-site complique les architectures multi-domaines

Sessions vs tokens

Les sessions sont appropriées pour les applications web traditionnelles où le serveur contrôle le client (SPAs, applications classiques). Pour les APIs consommées par des clients variés (mobile, tiers, M2M), les tokens porteurs (JWT, API keys) sont plus adaptés.

API Keys#

Une API key est une chaîne de caractères secrète utilisée comme credential pour identifier et authentifier un client API (application, service, utilisateur). Simple mais puissante pour les cas d’usage M2M.

Distribution et cycle de vie#

from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import APIKeyHeader
import hashlib, secrets

app = FastAPI()
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)

# En production : stockage en base avec hash, pas la valeur brute
API_KEYS_DB = {
    # sha256(key) -> {owner, scopes, active}
    "e3b0c44298fc...": {"owner": "service-facturation", "scopes": ["invoices:read"], "active": True},
}

def get_api_key(api_key: str = Depends(api_key_header)):
    if api_key is None:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
                            detail="API key manquante")
    key_hash = hashlib.sha256(api_key.encode()).hexdigest()
    if key_hash not in API_KEYS_DB or not API_KEYS_DB[key_hash]["active"]:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
                            detail="API key invalide ou révoquée")
    return API_KEYS_DB[key_hash]

@app.get("/api/v1/invoices")
async def list_invoices(key_info: dict = Depends(get_api_key)):
    if "invoices:read" not in key_info["scopes"]:
        raise HTTPException(status_code=403, detail="Scope insuffisant")
    return {"invoices": []}

Bonnes pratiques#

  • Ne jamais stocker la clé en clair — stocker son hash SHA-256 ou bcrypt

  • Génération : secrets.token_urlsafe(32) (Python stdlib) — 32 octets = 256 bits d’entropie

  • Préfixe lisible : sk_live_xxxxx ou pk_test_xxxxx permet d’identifier la clé visuellement et dans les logs

  • Scopes : chaque clé doit avoir des permissions limitées au minimum nécessaire

  • Rotation : prévoir un mécanisme de rotation sans interruption (période de chevauchement)

  • Header vs query param : préférer le header X-API-Key ou Authorization: Bearer au query param (les query params apparaissent dans les logs serveur)

API keys dans les logs

Si l’API key est transmise en query param (?api_key=xxx), elle apparaît dans les logs d’accès Nginx/Apache, les proxies, et potentiellement les historiques de navigation. Toujours utiliser un header.

JWT en détail#

Un JSON Web Token (RFC 7519) est un token porteur auto-contenu. Il encode des claims (affirmations) signées cryptographiquement, ce qui permet leur vérification sans interroger le serveur.

Structure#

Un JWT est composé de trois parties encodées en Base64url et séparées par des points :

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiJ1c2VyXzQyIiwiaXNzIjoiYXBpLmV4YW1wbGUuY29tIiwiZXhwIjoxNzQzMDAwMDAwfQ
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
  • Header : algorithme (alg) et type (typ)

  • Payload : claims — iss, sub, exp, iat, jti et claims personnalisés

  • Signature : HMAC ou signature asymétrique du header.payload

Claims standards#

Claim

Description

iss

Issuer — émetteur du token

sub

Subject — identifiant de l’utilisateur

aud

Audience — destinataire(s) prévu(s)

exp

Expiration time — timestamp Unix

iat

Issued at — timestamp d’émission

nbf

Not before — timestamp d’activation

jti

JWT ID — identifiant unique du token (révocation)

Algorithmes : HS256 vs RS256 vs ES256#

HS256 (HMAC-SHA256) — symétrique. La même clé secrète signe et vérifie. Simple, mais tous les vérificateurs doivent connaître le secret. Approprié quand l’émetteur = le vérificateur.

RS256 (RSA-SHA256) — asymétrique. La clé privée signe, la clé publique vérifie. Les vérificateurs (Resource Servers) n’ont accès qu’à la clé publique. Approprié pour les systèmes distribués.

ES256 (ECDSA-SHA256) — asymétrique, signature plus courte que RSA, performance cryptographique supérieure. Recommandé pour les nouveaux systèmes.

Jamais alg:none

L’algorithme none désactive la signature. Certaines bibliothèques JWT l’acceptaient historiquement, permettant la falsification de tokens. Toujours rejeter explicitement les tokens avec alg: none et valider que l’algorithme correspond à celui attendu.

Validation complète#

Une validation correcte d’un JWT doit vérifier dans l’ordre :

  1. Format (3 parties séparées par .)

  2. Base64url décodable

  3. Algorithme déclaré == algorithme attendu (liste blanche explicite)

  4. Signature valide

  5. exp > maintenant

  6. nbf <= maintenant (si présent)

  7. iss == émetteur attendu

  8. aud contient le service courant

  9. jti non présent dans la blacklist (si révocation activée)

Access token + Refresh token#

Le pattern standard pour les APIs OAuth :

  • Access token : courte durée (15 min — 1h), transmis dans chaque requête, stateless

  • Refresh token : longue durée (7–30 jours), stocké côté serveur (révocable), utilisé uniquement pour obtenir un nouvel access token

from fastapi import FastAPI, Depends, HTTPException
from datetime import datetime, timedelta, timezone
import hmac, hashlib, base64, json

SECRET = b"super-secret-key-32-bytes-minimum"
ALGORITHM = "HS256"

def b64url_encode(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()

def b64url_decode(s: str) -> bytes:
    padding = 4 - len(s) % 4
    return base64.urlsafe_b64decode(s + "=" * (padding % 4))

def create_access_token(user_id: str, scopes: list[str]) -> str:
    header = {"alg": "HS256", "typ": "JWT"}
    now = int(datetime.now(timezone.utc).timestamp())
    payload = {
        "iss": "api.example.com",
        "sub": user_id,
        "iat": now,
        "exp": now + 900,  # 15 minutes
        "scopes": scopes,
        "jti": b64url_encode(hashlib.sha256(
            f"{user_id}{now}".encode()
        ).digest()),
    }
    h = b64url_encode(json.dumps(header, separators=(",", ":")).encode())
    p = b64url_encode(json.dumps(payload, separators=(",", ":")).encode())
    message = f"{h}.{p}".encode()
    sig = hmac.new(SECRET, message, hashlib.sha256).digest()
    return f"{h}.{p}.{b64url_encode(sig)}"

OAuth 2.0#

OAuth 2.0 (RFC 6749) est un framework d’autorisation délégué. Il permet à une application tierce d’accéder à des ressources au nom d’un utilisateur, sans que l’application ne connaisse ses credentials.

Les quatre rôles#

  • Resource Owner : l’utilisateur qui possède les données

  • Client : l’application qui demande l’accès

  • Authorization Server (AS) : émet les tokens après authentification

  • Resource Server (RS) : héberge les ressources protégées, valide les tokens

Authorization Code + PKCE#

Le flux le plus sécurisé, utilisé pour les SPAs, applications mobiles, et applications web.

PKCE (Proof Key for Code Exchange, RFC 7636) protège contre l’interception du code d’autorisation. Le client génère un code_verifier aléatoire et envoie son hash code_challenge au démarrage du flux.

Étapes :

  1. Client génère code_verifier (43–128 caractères aléatoires)

  2. Client calcule code_challenge = BASE64URL(SHA256(code_verifier))

  3. Redirect vers l’AS avec code_challenge et code_challenge_method=S256

  4. Utilisateur s’authentifie sur l’AS

  5. AS redirige vers le client avec le code d’autorisation

  6. Client échange le code contre des tokens en présentant le code_verifier

  7. L’AS vérifie que SHA256(code_verifier) == code_challenge stocké

import secrets, hashlib, base64, urllib.parse

def generate_pkce() -> tuple[str, str]:
    """Génère un code_verifier et son code_challenge PKCE."""
    verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
    digest = hashlib.sha256(verifier.encode()).digest()
    challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
    return verifier, challenge

def build_authorization_url(
    as_url: str,
    client_id: str,
    redirect_uri: str,
    scopes: list[str],
    code_challenge: str,
    state: str
) -> str:
    params = {
        "response_type": "code",
        "client_id": client_id,
        "redirect_uri": redirect_uri,
        "scope": " ".join(scopes),
        "code_challenge": code_challenge,
        "code_challenge_method": "S256",
        "state": state,
    }
    return f"{as_url}/authorize?{urllib.parse.urlencode(params)}"

Client Credentials (M2M)#

Flux pour les communications machine à machine — aucun utilisateur impliqué.

POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret)

grant_type=client_credentials&scope=reports:read+reports:write

Le service reçoit directement un access token. Pas de redirect, pas d’interaction utilisateur. Le token représente le service lui-même, pas un utilisateur.

Device Flow#

Pour les appareils sans navigateur ou à interface limitée (TV connectées, CLI, IoT) :

  1. Le device demande un device_code et un user_code

  2. L’utilisateur va sur une URL (verification_uri) sur un autre appareil et saisit le user_code

  3. Le device poll régulièrement l’AS en présentant le device_code

  4. Une fois l’utilisateur authentifié, le poll retourne les tokens

Refresh Token flow#

POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&refresh_token=<token>&client_id=<id>

L’AS invalide l’ancien refresh token et en émet un nouveau (refresh token rotation). Si un refresh token déjà révoqué est présenté, l’AS doit considérer toute la famille de tokens comme compromise (refresh token reuse detection).

OpenID Connect#

OpenID Connect (OIDC) est une couche d’identité construite sur OAuth 2.0. Là où OAuth gère l’autorisation (accès à des ressources), OIDC gère l’authentification (qui est l’utilisateur).

ID Token#

OIDC ajoute un ID Token (JWT) aux tokens OAuth. Il contient les informations d’identité de l’utilisateur :

{
  "iss": "https://accounts.google.com",
  "sub": "110169484474386276334",
  "aud": "client_id_de_l_application",
  "exp": 1742900400,
  "iat": 1742896800,
  "name": "Alice Martin",
  "email": "alice@example.com",
  "email_verified": true,
  "nonce": "abc123"
}

Le nonce protège contre les attaques de replay.

UserInfo endpoint#

L’application peut obtenir des informations supplémentaires sur l’utilisateur :

GET /userinfo HTTP/1.1
Authorization: Bearer <access_token>

Discovery#

OIDC définit un endpoint de discovery standardisé :

GET https://accounts.example.com/.well-known/openid-configuration

La réponse JSON décrit tous les endpoints (authorization, token, userinfo, JWKS), algorithmes supportés, scopes disponibles. Cela permet aux clients de configurer automatiquement l’intégration.

Scopes OIDC#

Scope

Claims fournis

openid

sub (obligatoire pour OIDC)

profile

name, given_name, family_name, locale, …

email

email, email_verified

address

address (structuré)

phone

phone_number, phone_number_verified

Implémentation FastAPI#

OAuth2PasswordBearer et dépendances#

from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
import hmac, hashlib, base64, json
from datetime import datetime, timezone

app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")

SECRET = b"dev-secret-change-in-production-32b"

def verify_token(token: str) -> dict:
    """Valide un JWT HS256 simplifié."""
    parts = token.split(".")
    if len(parts) != 3:
        raise ValueError("Format JWT invalide")

    header_b64, payload_b64, sig_b64 = parts
    message = f"{header_b64}.{payload_b64}".encode()

    expected_sig = hmac.new(SECRET, message, hashlib.sha256).digest()
    expected_b64 = base64.urlsafe_b64encode(expected_sig).rstrip(b"=").decode()
    if not hmac.compare_digest(sig_b64, expected_b64):
        raise ValueError("Signature invalide")

    padding = 4 - len(payload_b64) % 4
    payload = json.loads(base64.urlsafe_b64decode(payload_b64 + "=" * (padding % 4)))

    now = int(datetime.now(timezone.utc).timestamp())
    if payload.get("exp", 0) < now:
        raise ValueError("Token expiré")
    if payload.get("iss") != "api.example.com":
        raise ValueError("Issuer invalide")

    return payload

async def get_current_user(token: str = Depends(oauth2_scheme)) -> dict:
    try:
        payload = verify_token(token)
    except ValueError as e:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail=str(e),
            headers={"WWW-Authenticate": "Bearer"},
        )
    return payload

async def require_scope(scope: str):
    """Factory de dépendance pour les scopes."""
    async def dependency(user: dict = Depends(get_current_user)):
        if scope not in user.get("scopes", []):
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f"Scope requis : {scope}"
            )
        return user
    return dependency

@app.get("/api/v1/reports")
async def get_reports(user: dict = Depends(require_scope("reports:read"))):
    return {"reports": [], "user": user["sub"]}

Autorisation#

L’autorisation détermine ce qu’un utilisateur authentifié est autorisé à faire. Plusieurs modèles coexistent.

RBAC — Role-Based Access Control#

Les permissions sont attachées à des rôles, les rôles sont assignés aux utilisateurs.

from enum import Enum
from fastapi import FastAPI, Depends, HTTPException

class Role(str, Enum):
    ADMIN = "admin"
    EDITOR = "editor"
    VIEWER = "viewer"

ROLE_PERMISSIONS = {
    Role.ADMIN:  {"read", "write", "delete", "manage_users"},
    Role.EDITOR: {"read", "write"},
    Role.VIEWER: {"read"},
}

def require_permission(permission: str):
    async def dependency(user: dict = Depends(get_current_user)):
        role = Role(user.get("role", "viewer"))
        if permission not in ROLE_PERMISSIONS.get(role, set()):
            raise HTTPException(status_code=403,
                                detail=f"Permission '{permission}' requise")
        return user
    return dependency

ABAC — Attribute-Based Access Control#

Les décisions d’autorisation sont basées sur des attributs de l’utilisateur, de la ressource et du contexte. Plus flexible que RBAC mais plus complexe à implémenter.

def abac_check(user: dict, resource: dict, action: str) -> bool:
    """Exemple : accès basé sur l'appartenance organisationnelle."""
    if user.get("role") == "admin":
        return True
    if action == "read" and resource.get("visibility") == "public":
        return True
    if resource.get("owner_org") == user.get("org_id"):
        return action in ("read", "write")
    return False

Broken Object Level Authorization (BOLA)#

La vulnérabilité la plus fréquente dans les APIs : un utilisateur accède aux ressources d’un autre en changeant l’identifiant dans l’URL.

@app.get("/api/v1/invoices/{invoice_id}")
async def get_invoice(invoice_id: int, user: dict = Depends(get_current_user)):
    invoice = db.get_invoice(invoice_id)
    if not invoice:
        raise HTTPException(status_code=404)
    # CRITIQUE : vérifier que l'invoice appartient bien à l'utilisateur
    if invoice["user_id"] != user["sub"] and "admin" not in user.get("roles", []):
        raise HTTPException(status_code=403, detail="Accès non autorisé")
    return invoice

Scopes OAuth comme autorisation#

Les scopes OAuth permettent une autorisation fine au niveau de l’API :

read:users       → lire les utilisateurs
write:users      → créer/modifier les utilisateurs
admin:users      → supprimer, changer les rôles
read:invoices    → lire les factures
write:invoices   → créer des factures

La convention ressource:action est lisible et extensible.


Cellules d’analyse et de visualisation#

Décodage et validation d’un JWT simulé#

import hmac, hashlib, base64, json, time
from datetime import datetime, timezone

SECRET = b"cle-secrete-demonstration-32bytes"

def b64url_encode(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()

def b64url_decode(s: str) -> bytes:
    padding = 4 - len(s) % 4
    return base64.urlsafe_b64decode(s + "=" * (padding % 4))

def create_jwt(payload: dict, secret: bytes) -> str:
    header = {"alg": "HS256", "typ": "JWT"}
    h = b64url_encode(json.dumps(header, separators=(",", ":")).encode())
    p = b64url_encode(json.dumps(payload, separators=(",", ":")).encode())
    message = f"{h}.{p}".encode()
    sig = hmac.new(secret, message, hashlib.sha256).digest()
    return f"{h}.{p}.{b64url_encode(sig)}"

def validate_jwt(token: str, secret: bytes, expected_iss: str) -> tuple[bool, str, dict]:
    """Retourne (valide, message, payload)."""
    parts = token.split(".")
    if len(parts) != 3:
        return False, "Format invalide (3 parties attendues)", {}

    header_b64, payload_b64, sig_b64 = parts

    # Décoder le header pour vérifier l'algorithme
    try:
        header = json.loads(b64url_decode(header_b64))
    except Exception:
        return False, "Header non décodable", {}

    if header.get("alg") != "HS256":
        return False, f"Algorithme inattendu : {header.get('alg')}", {}

    # Vérifier la signature
    message = f"{header_b64}.{payload_b64}".encode()
    expected_sig = hmac.new(secret, message, hashlib.sha256).digest()
    expected_b64 = b64url_encode(expected_sig)
    if not hmac.compare_digest(sig_b64, expected_b64):
        return False, "Signature invalide", {}

    # Décoder le payload
    try:
        payload = json.loads(b64url_decode(payload_b64))
    except Exception:
        return False, "Payload non décodable", {}

    # Vérifier l'expiration
    now = int(datetime.now(timezone.utc).timestamp())
    if "exp" in payload and payload["exp"] < now:
        return False, f"Token expiré (exp={payload['exp']}, now={now})", payload

    # Vérifier l'issuer
    if payload.get("iss") != expected_iss:
        return False, f"Issuer invalide : attendu '{expected_iss}', reçu '{payload.get('iss')}'", payload

    return True, "Token valide", payload

# Création d'un token valide
now = int(datetime.now(timezone.utc).timestamp())
payload_valide = {
    "iss": "api.example.com",
    "sub": "user_42",
    "aud": "app.example.com",
    "iat": now,
    "exp": now + 900,
    "scopes": ["read:reports", "write:invoices"],
    "jti": b64url_encode(hashlib.sha256(f"user_42{now}".encode()).digest()[:12]),
}

token = create_jwt(payload_valide, SECRET)
print("=== JWT généré ===")
parts = token.split(".")
print(f"Header  : {parts[0]}")
print(f"Payload : {parts[1]}")
print(f"Signature: {parts[2][:20]}...")
print()

# Décodage manuel
header_decoded = json.loads(b64url_decode(parts[0]))
payload_decoded = json.loads(b64url_decode(parts[1]))
print("=== Header décodé ===")
print(json.dumps(header_decoded, indent=2))
print()
print("=== Payload décodé ===")
payload_display = payload_decoded.copy()
payload_display["exp_human"] = datetime.fromtimestamp(payload_decoded["exp"]).isoformat()
payload_display["iat_human"] = datetime.fromtimestamp(payload_decoded["iat"]).isoformat()
print(json.dumps(payload_display, indent=2, ensure_ascii=False))
print()

# Tests de validation
print("=== Tests de validation ===")
cas_tests = [
    ("Token valide", token, SECRET, "api.example.com"),
    ("Mauvais secret", token, b"mauvaise-cle-secrete", "api.example.com"),
    ("Mauvais issuer", token, SECRET, "autre.domaine.com"),
    ("Token malformé", "pas.un.jwt", SECRET, "api.example.com"),
    ("Token expiré", create_jwt({**payload_valide, "exp": now - 100}, SECRET), SECRET, "api.example.com"),
    ("Alg:none attack", b64url_encode(b'{"alg":"none","typ":"JWT"}') + "." +
     b64url_encode(json.dumps(payload_valide).encode()) + ".", SECRET, "api.example.com"),
]

for label, tok, sec, iss in cas_tests:
    valid, msg, _ = validate_jwt(tok, sec, iss)
    status = "VALIDE" if valid else "REJETÉ"
    print(f"  [{status}] {label}: {msg}")
=== JWT généré ===
Header  : eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Payload : eyJpc3MiOiJhcGkuZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyXzQyIiwiYXVkIjoiYXBwLmV4YW1wbGUuY29tIiwiaWF0IjoxNzc0NTE4Mzg1LCJleHAiOjE3NzQ1MTkyODUsInNjb3BlcyI6WyJyZWFkOnJlcG9ydHMiLCJ3cml0ZTppbnZvaWNlcyJdLCJqdGkiOiJ5UzNCREs1d3c0WGhPMkRRIn0
Signature: RoqqcMJGKI9eckpOlrAI...

=== Header décodé ===
{
  "alg": "HS256",
  "typ": "JWT"
}

=== Payload décodé ===
{
  "iss": "api.example.com",
  "sub": "user_42",
  "aud": "app.example.com",
  "iat": 1774518385,
  "exp": 1774519285,
  "scopes": [
    "read:reports",
    "write:invoices"
  ],
  "jti": "yS3BDK5ww4XhO2DQ",
  "exp_human": "2026-03-26T11:01:25",
  "iat_human": "2026-03-26T10:46:25"
}

=== Tests de validation ===
  [VALIDE] Token valide: Token valide
  [REJETÉ] Mauvais secret: Signature invalide
  [REJETÉ] Mauvais issuer: Issuer invalide : attendu 'autre.domaine.com', reçu 'api.example.com'
  [REJETÉ] Token malformé: Header non décodable
  [REJETÉ] Token expiré: Token expiré (exp=1774518285, now=1774518385)
  [REJETÉ] Alg:none attack: Algorithme inattendu : none

Diagramme Authorization Code + PKCE#

import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

fig, ax = plt.subplots(figsize=(13, 10))
ax.set_xlim(0, 13)
ax.set_ylim(0, 15)
ax.axis("off")
fig.patch.set_facecolor("#f8f9fa")
ax.set_facecolor("#f8f9fa")

actors = {
    "Utilisateur": 1.5,
    "SPA / App mobile": 4.0,
    "Authorization Server": 7.5,
    "Resource Server": 11.5,
}

actor_colors = {
    "Utilisateur": "#5C85D6",
    "SPA / App mobile": "#4CAF50",
    "Authorization Server": "#FF9800",
    "Resource Server": "#9C27B0",
}

for name, x in actors.items():
    color = actor_colors[name]
    ax.text(x, 14.5, name, ha="center", va="center", fontsize=9.5,
            fontweight="bold",
            bbox=dict(boxstyle="round,pad=0.4", facecolor=color,
                      edgecolor="none", alpha=0.85), color="white")
    ax.plot([x, x], [0.5, 14.2], color=color, lw=1.0,
            linestyle="--", alpha=0.4)

def flow_arrow(ax, x1, x2, y, label, color="#444", sub="", dashed=False):
    style = "-->" if dashed else "-|>"
    ax.annotate("", xy=(x2, y), xytext=(x1, y),
                arrowprops=dict(arrowstyle="-|>", color=color, lw=1.4,
                                linestyle="dashed" if dashed else "solid",
                                mutation_scale=12))
    mid = (x1 + x2) / 2
    ax.text(mid, y + 0.22, label, ha="center", va="bottom",
            fontsize=8, color=color, fontweight="semibold")
    if sub:
        ax.text(mid, y - 0.18, sub, ha="center", va="top",
                fontsize=7, color="#777777", style="italic")

def step_label(ax, y, n, text):
    ax.text(0.1, y, f"({n})", fontsize=8, color="#888888", va="center")

# Étapes PKCE
step_label(ax, 13.7, 1, "")
ax.text(6.5, 13.75, "① PKCE : génération de code_verifier + code_challenge",
        ha="center", fontsize=8.5, color="#555",
        bbox=dict(boxstyle="round,pad=0.2", facecolor="#FFF9C4", edgecolor="#F0D060"))

flow_arrow(ax, 1.5, 4.0, 13.0, "Clique 'Se connecter'", actor_colors["Utilisateur"])
flow_arrow(ax, 4.0, 7.5, 12.2,
           "Redirect → /authorize",
           actor_colors["SPA / App mobile"],
           sub="?code_challenge=SHA256(verifier)&code_challenge_method=S256&scope=openid profile")

flow_arrow(ax, 7.5, 1.5, 11.2,
           "Page de connexion",
           actor_colors["Authorization Server"])
flow_arrow(ax, 1.5, 7.5, 10.4,
           "Saisit login + mot de passe",
           actor_colors["Utilisateur"])

ax.text(6.5, 9.85, "② Authentification réussie — AS génère le code d'autorisation",
        ha="center", fontsize=8.5, color="#555",
        bbox=dict(boxstyle="round,pad=0.2", facecolor="#FFF9C4", edgecolor="#F0D060"))

flow_arrow(ax, 7.5, 4.0, 9.3,
           "Redirect → redirect_uri",
           actor_colors["Authorization Server"],
           sub="?code=AUTH_CODE&state=xyz")

ax.text(6.5, 8.75, "③ Échange code → tokens (présentation du code_verifier)",
        ha="center", fontsize=8.5, color="#555",
        bbox=dict(boxstyle="round,pad=0.2", facecolor="#FFF9C4", edgecolor="#F0D060"))

flow_arrow(ax, 4.0, 7.5, 8.2,
           "POST /token",
           actor_colors["SPA / App mobile"],
           sub="code=AUTH_CODE + code_verifier (AS vérifie SHA256(verifier)==challenge)")

flow_arrow(ax, 7.5, 4.0, 7.2,
           "access_token + id_token + refresh_token",
           actor_colors["Authorization Server"])

ax.text(6.5, 6.65, "④ Utilisation de l'access token",
        ha="center", fontsize=8.5, color="#555",
        bbox=dict(boxstyle="round,pad=0.2", facecolor="#FFF9C4", edgecolor="#F0D060"))

flow_arrow(ax, 4.0, 11.5, 6.1,
           "GET /api/resource",
           actor_colors["SPA / App mobile"],
           sub="Authorization: Bearer <access_token>")
flow_arrow(ax, 11.5, 4.0, 5.1,
           "200 OK — données protégées",
           actor_colors["Resource Server"])

ax.text(6.5, 4.5, "⑤ Renouvellement silencieux via refresh token",
        ha="center", fontsize=8.5, color="#555",
        bbox=dict(boxstyle="round,pad=0.2", facecolor="#FFF9C4", edgecolor="#F0D060"))

flow_arrow(ax, 4.0, 7.5, 3.9,
           "POST /token (grant_type=refresh_token)",
           actor_colors["SPA / App mobile"])
flow_arrow(ax, 7.5, 4.0, 3.0,
           "Nouveaux access_token + refresh_token (rotation)",
           actor_colors["Authorization Server"])

ax.set_title("Flux OAuth 2.0 Authorization Code + PKCE",
             fontsize=13, fontweight="bold", pad=8)
plt.savefig("oauth_pkce_flow.png", dpi=120, bbox_inches="tight",
            facecolor="#f8f9fa")
plt.show()
_images/684425497aa3114652de24443bbcce99a32711257a667ba061feb4ca0e187ca7.png

Diagramme Client Credentials (M2M)#

import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

fig, ax = plt.subplots(figsize=(11, 6))
ax.set_xlim(0, 11)
ax.set_ylim(0, 9)
ax.axis("off")
fig.patch.set_facecolor("#f8f9fa")
ax.set_facecolor("#f8f9fa")

actors_m2m = {"Service A\n(Client)", "Authorization Server", "Service B\n(Resource Server)"}
positions = {"Service A\n(Client)": 1.5, "Authorization Server": 5.5, "Service B\n(Resource Server)": 9.5}
cols_m2m = {"Service A\n(Client)": "#4CAF50", "Authorization Server": "#FF9800", "Service B\n(Resource Server)": "#9C27B0"}

for name, x in positions.items():
    c = cols_m2m[name]
    ax.text(x, 8.5, name, ha="center", va="center", fontsize=10,
            fontweight="bold", color="white",
            bbox=dict(boxstyle="round,pad=0.4", facecolor=c, edgecolor="none"))
    ax.plot([x, x], [1.0, 8.2], color=c, lw=1.2, linestyle="--", alpha=0.4)

def m2m_arrow(ax, x1, x2, y, label, color, sub=""):
    ax.annotate("", xy=(x2, y), xytext=(x1, y),
                arrowprops=dict(arrowstyle="-|>", color=color, lw=1.5, mutation_scale=13))
    mid = (x1 + x2) / 2
    ax.text(mid, y + 0.22, label, ha="center", va="bottom", fontsize=9, color=color)
    if sub:
        ax.text(mid, y - 0.2, sub, ha="center", va="top", fontsize=7.5,
                color="#666666", style="italic")

# Étape 1
ax.text(3.5, 7.5, "① Authentification du service (pas d'utilisateur)",
        ha="center", fontsize=8.5, color="#444",
        bbox=dict(boxstyle="round,pad=0.25", facecolor="#FFF9C4", edgecolor="#E0C000"))
m2m_arrow(ax, 1.5, 5.5, 6.9,
          "POST /oauth/token",
          cols_m2m["Service A\n(Client)"],
          sub="grant_type=client_credentials&scope=reports:read  (Basic Auth: client_id:secret)")
m2m_arrow(ax, 5.5, 1.5, 5.9,
          "access_token (JWT, exp=3600s)",
          cols_m2m["Authorization Server"])

# Étape 2
ax.text(5.5, 5.2, "② Appel API avec le token — valide pour toute la durée",
        ha="center", fontsize=8.5, color="#444",
        bbox=dict(boxstyle="round,pad=0.25", facecolor="#FFF9C4", edgecolor="#E0C000"))
m2m_arrow(ax, 1.5, 9.5, 4.6,
          "GET /api/reports",
          cols_m2m["Service A\n(Client)"],
          sub="Authorization: Bearer <access_token>")
m2m_arrow(ax, 9.5, 1.5, 3.6,
          "200 OK — données",
          cols_m2m["Service B\n(Resource Server)"])

# Étape 3 (renouvellement)
ax.text(3.5, 3.0, "③ Renouvellement automatique avant expiration",
        ha="center", fontsize=8.5, color="#444",
        bbox=dict(boxstyle="round,pad=0.25", facecolor="#FFF9C4", edgecolor="#E0C000"))
m2m_arrow(ax, 1.5, 5.5, 2.4,
          "POST /oauth/token (nouveau cycle)",
          cols_m2m["Service A\n(Client)"])
m2m_arrow(ax, 5.5, 1.5, 1.5,
          "Nouveau access_token",
          cols_m2m["Authorization Server"])

ax.set_title("Flux OAuth 2.0 Client Credentials — communication M2M",
             fontsize=12, fontweight="bold", pad=8)
plt.savefig("oauth_client_credentials.png", dpi=120, bbox_inches="tight",
            facecolor="#f8f9fa")
plt.show()
_images/a1ab88243b451040c3c8d787e24e52ea244cb1fac2be06343b8ab3f0f2c929c3.png

Comparaison des mécanismes d’authentification#

import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

mecanismes = ["Cookie\nde session", "API Key", "JWT\nHS256", "JWT\nRS256/ES256",
              "OAuth 2.0\nAuth Code + PKCE", "OAuth 2.0\nClient Credentials", "mTLS"]

criteres = ["Sécurité", "Simplicité\nimplémentation", "Scalabilité\nhorizontale",
            "Révocation\naisée", "Adapté\nnavigateur", "Adapté\nM2M"]

# Scores 1-5 (5 = excellent)
scores = np.array([
    # Sécu  Simplic  Scal  Révoc  Nav   M2M
    [3,     5,       3,    5,     5,    1],   # Session cookie
    [3,     5,       5,    4,     2,    5],   # API Key
    [4,     3,       5,    2,     3,    4],   # JWT HS256
    [5,     2,       5,    3,     3,    4],   # JWT RS256
    [5,     2,       5,    4,     5,    2],   # OAuth Auth Code + PKCE
    [5,     3,       5,    3,     1,    5],   # OAuth Client Credentials
    [5,     1,       5,    5,     1,    5],   # mTLS
])

fig, ax = plt.subplots(figsize=(12, 5.5))

x = np.arange(len(criteres))
n = len(mecanismes)
width = 0.11
palette = sns.color_palette("muted", n)

for i, (mec, score_row) in enumerate(zip(mecanismes, scores)):
    offset = (i - n/2 + 0.5) * width
    bars = ax.bar(x + offset, score_row, width * 0.88,
                  label=mec.replace("\n", " "),
                  color=palette[i], alpha=0.85, edgecolor="white")

ax.set_xticks(x)
ax.set_xticklabels(criteres, fontsize=9.5)
ax.set_yticks([1, 2, 3, 4, 5])
ax.set_yticklabels(["1\nMinimal", "2\nFaible", "3\nMoyen", "4\nBon", "5\nExcellent"],
                   fontsize=8.5)
ax.set_ylim(0, 6.2)
ax.set_ylabel("Score (1–5)", fontsize=10)
ax.set_title("Comparaison des mécanismes d'authentification pour les APIs",
             fontsize=12, fontweight="bold", pad=10)
ax.legend(loc="upper right", fontsize=8, ncol=2,
          bbox_to_anchor=(1.0, 1.0))
ax.grid(axis="y", alpha=0.4)

plt.savefig("auth_mechanisms_comparison.png", dpi=120, bbox_inches="tight")
plt.show()

print("\nRésumé par cas d'usage :")
print("  Application web SPA       → OAuth 2.0 Authorization Code + PKCE")
print("  Application mobile native → OAuth 2.0 Authorization Code + PKCE")
print("  Service à service (M2M)   → OAuth 2.0 Client Credentials ou mTLS")
print("  API simple (usage interne)→ API Key + TLS")
print("  Application legacy web    → Cookie de session + SameSite=Lax")
print("  Haute sécurité (finance)  → mTLS + JWT")
_images/f5dd79c1cb9f1aee50f5bcca15a976025056b2e1530e42bb7aaf5e3953e0e505.png
Résumé par cas d'usage :
  Application web SPA       → OAuth 2.0 Authorization Code + PKCE
  Application mobile native → OAuth 2.0 Authorization Code + PKCE
  Service à service (M2M)   → OAuth 2.0 Client Credentials ou mTLS
  API simple (usage interne)→ API Key + TLS
  Application legacy web    → Cookie de session + SameSite=Lax
  Haute sécurité (finance)  → mTLS + JWT

Résumé#

Ce chapitre a couvert les mécanismes d’authentification et d’autorisation des APIs modernes :

Sessions et cookies — adaptées aux applications web classiques avec serveur stateful. Les attributs HttpOnly, Secure, et SameSite=Lax sont le minimum de sécurité. Limitées pour les APIs multi-clients.

API Keys — simples et efficaces pour le M2M. Ne jamais stocker en clair (hash SHA-256), utiliser les headers plutôt que les query params, assigner des scopes minimaux.

JWT — tokens auto-contenus avec expiration. La validation doit être complète : format, algorithme explicite (liste blanche), signature, expiration, issuer, audience. Utiliser RS256 ou ES256 pour les architectures distribuées. Le pattern access + refresh token est le standard.

OAuth 2.0 — Authorization Code + PKCE pour les clients publics (SPA, mobile) ; Client Credentials pour le M2M ; Device Flow pour les appareils sans navigateur. La rotation des refresh tokens est obligatoire.

OpenID Connect — ajoute la couche d’identité (ID Token, UserInfo) sur OAuth 2.0. Le discovery via .well-known/openid-configuration simplifie l’intégration.

Autorisation — RBAC pour la simplicité, ABAC pour la finesse. La vérification de l’appartenance d’une ressource à l’utilisateur (BOLA) est la protection la plus critique et la plus souvent omise.