Auth (OAuth2 / JWT)
TL;DR — En FastAPI, l'authentification n'est pas un middleware magique : c'est une dépendance (
Depends) qui transforme unAuthorization: Bearer <token>en un objetCurrentUsertypé, et qui lève401/403quand le token est absent, expiré ou insuffisant. Le standard que tout le monde appelle « JWT auth » est en réalité OAuth2 Password Bearer : le client échange identifiant + mot de passe contre un access token (un JWT signé, courte durée de vie), qu'il rejoue sur chaque requête. Côté serveur, on hashe les mots de passe avecargon2/bcrypt(jamais en clair), on signe les JWT (HS256 avec un secret, ou RS256 avec une paire de clés), et on vérifie signature + expiration + claims à chaque appel. Les pièges senior ne sont pas dans la crypto (les libs la gèrent) mais dans le modèle : access token court + refresh token long, révocation (un JWT est par nature non-révocable — il faut une dénylist ou des tokens opaques), séparation authentification (qui es-tu) / autorisation (as-tu le droit), et fuite de secret. Pour une app qui sert un agent Anthropic, l'enjeu concret : protéger l'endpoint/agents/run(coûteux en tokens LLM, donc en argent) derrière un scope, attribuer chaque appel à unuser_idpour le rate-limiting et la facturation, et ne jamais laisser le secret JWT ou la clé API Anthropic transiter par le client.
🧠 Mental model
L'authentification par token, c'est le bracelet d'un festival. À l'entrée (le /login), un agent vérifie votre billet (identifiant + mot de passe) et vous pose un bracelet inviolable au poignet (l'access token). À chaque scène, le vigile ne rappelle pas la billetterie : il regarde le bracelet, vérifie qu'il n'est pas contrefait (la signature), qu'il n'est pas périmé (l'expiration), et lit dessus à quelles zones vous avez accès (les scopes). Le serveur est sans mémoire de qui vous êtes entre deux requêtes — toute l'information tient sur le bracelet.
C'est là tout l'intérêt d'un JWT : il est auto-porteur (self-contained). Le vigile n'a pas besoin de base de données pour savoir qui vous êtes. C'est aussi sa principale faiblesse : si vous quittez le festival, le bracelet reste valide jusqu'à sa péremption — on ne peut pas le couper à distance. C'est tout le drame de la révocation, et la raison d'être des refresh tokens et des dénylists.
POST /login GET /agents/run
{user, password} Authorization: Bearer eyJhbGci...
│ │
▼ ▼
┌───────────┐ vérifie hash ┌──────────────────┐
│ /login │ (argon2.verify) │ Depends( │
│ │──────────────┐ │ get_current_ │
│ signe un │ │ │ user) │
│ JWT │ ▼ │ │
└─────┬─────┘ ┌──────────────┐ │ 1. extrait Bearer│
│ │ users (DB) │ │ 2. jwt.decode │ ← signature + exp
│ access │ password_hash│ │ 3. charge user │
│ token └──────────────┘ │ 4. check scope │ ← autorisation
▼ └────────┬─────────┘
┌─────────┐ │ CurrentUser
│ client │ rejoue le token ▼
│ (stocke)│ sur chaque requête handler métier
└─────────┘ (appelle l'agent Anthropic)La ligne mentale à ne jamais franchir : authentification ≠ autorisation. get_current_user répond à « qui es-tu ? » (401 si la réponse est « personne »). Le check de scope répond à « as-tu le droit de faire ça ? » (403 si « non »). Deux dépendances, deux codes HTTP, deux responsabilités.
Le vocabulaire OAuth2, démystifié
Vous venez de PHP/TS, vous avez sûrement vu passer « OAuth2 » comme un truc compliqué avec Google. En réalité, pour une API à vous, on utilise un seul de ses flux : le Resource Owner Password Credentials (dit « password flow »). Le client envoie le mot de passe à votre propre API (pas à un tiers), reçoit un token, c'est tout.
| Terme | Ce que c'est concrètement |
|---|---|
| Access token | Le JWT court (15 min). Rejoué sur chaque requête. S'il fuite, dégât limité dans le temps. |
| Refresh token | Token long (7-30 j), rejoué uniquement sur /refresh pour obtenir un nouvel access token. Stocké côté serveur pour pouvoir le révoquer. |
| Claims | Les champs du JWT : sub (subject = user id), exp (expiration), iat (issued at), scope, etc. |
| Scope | Une permission (agents:run, admin). L'unité d'autorisation. |
| Bearer | Le schéma HTTP : Authorization: Bearer <token>. « Porteur » = quiconque détient le token est traité comme le titulaire. D'où l'obsession de ne pas le faire fuiter. |
⚠️ Un JWT n'est pas chiffré, il est signé. N'importe qui peut lire son contenu (c'est du base64, collez-le sur jwt.io). La signature garantit qu'il n'a pas été modifié, pas qu'il est secret. Ne mettez jamais de données sensibles dans un JWT.
Le socle : hasher les mots de passe
On commence par le plus important et le plus bâclé en pratique. On ne stocke jamais un mot de passe, on stocke son hash, calculé avec une fonction lente et salée conçue pour ça. En 2026 le défaut recommandé est argon2 (vainqueur de la Password Hashing Competition) ; bcrypt reste acceptable.
# app/security/passwords.py
from __future__ import annotations
from pwdlib import PasswordHash
# pwdlib est le successeur moderne de passlib (qui n'est plus maintenu).
# argon2 par défaut, avec rehash automatique si les paramètres de coût changent.
_hasher = PasswordHash.recommended()
def hash_password(plain: str) -> str:
return _hasher.hash(plain)
def verify_password(plain: str, stored_hash: str) -> tuple[bool, str | None]:
"""Retourne (valide, nouveau_hash_si_besoin).
Le second élément est non-None quand le hash doit être recalculé
(paramètres de coût obsolètes) : on en profite pour le mettre à jour en DB.
"""
return _hasher.verify_and_update(plain, stored_hash)# ❌ La mauvaise façon, vue trop souvent
import hashlib
def hash_password_wrong(plain: str) -> str:
# SHA-256 est RAPIDE : c'est exactement ce qu'on ne veut pas.
# Pas de sel → deux users avec le même mot de passe ont le même hash.
# Un GPU teste des milliards de SHA-256/s. Argon2 est conçu pour être lent
# et gourmand en mémoire, ce qui rend le brute-force économiquement absurde.
return hashlib.sha256(plain.encode()).hexdigest()Le point subtil que les seniors connaissent : verify_password doit prendre un temps constant, même quand l'utilisateur n'existe pas. Si « user inconnu » répond en 1 ms et « mauvais mot de passe » en 100 ms, un attaquant énumère vos comptes par timing. La parade : vérifier toujours contre un hash, même factice.
# app/security/passwords.py (suite)
# Hash d'un mot de passe bidon, calculé une fois au démarrage.
_DUMMY_HASH = _hasher.hash("dummy-password-to-equalize-timing")
def verify_against_dummy() -> None:
"""À appeler quand l'utilisateur n'existe pas, pour égaliser le timing."""
_hasher.verify("whatever", _DUMMY_HASH)Signer et vérifier les JWT
On signe avec pyjwt. HS256 (secret partagé symétrique) pour commencer ; RS256 (clé privée/publique) quand plusieurs services doivent vérifier sans pouvoir émettre (voir la section production).
# app/security/tokens.py
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from typing import Literal
import jwt
from pydantic import BaseModel, Field
from app.settings import settings # cf. leçon 01-settings : secret via env, jamais en dur
ALGORITHM = "HS256"
ACCESS_TTL = timedelta(minutes=15)
REFRESH_TTL = timedelta(days=14)
class TokenPayload(BaseModel):
"""Représentation typée et validée des claims — ne jamais faire confiance à un dict brut."""
sub: str # user id
exp: datetime
iat: datetime
jti: str # JWT ID — identifiant unique, sert à la révocation
type: Literal["access", "refresh"]
scopes: list[str] = Field(default_factory=list)
def _create_token(
user_id: str,
token_type: Literal["access", "refresh"],
ttl: timedelta,
scopes: list[str],
) -> str:
now = datetime.now(UTC)
payload = TokenPayload(
sub=user_id,
iat=now,
exp=now + ttl,
jti=str(uuid.uuid4()),
type=token_type,
scopes=scopes,
)
# On passe les datetime tels quels (pas model_dump(mode="json")) : PyJWT
# convertit lui-même exp/iat en timestamps POSIX numériques, le seul format
# que jwt.decode sait valider. Des chaînes ISO casseraient la vérification d'exp.
return jwt.encode(
{
"sub": payload.sub,
"iat": payload.iat,
"exp": payload.exp,
"jti": payload.jti,
"type": payload.type,
"scopes": payload.scopes,
},
settings.jwt_secret.get_secret_value(),
algorithm=ALGORITHM,
)
def create_access_token(user_id: str, scopes: list[str]) -> str:
return _create_token(user_id, "access", ACCESS_TTL, scopes)
def create_refresh_token(user_id: str) -> str:
# Le refresh token ne porte pas de scopes : il ne sert qu'à régénérer un access token.
return _create_token(user_id, "refresh", REFRESH_TTL, scopes=[])
def decode_token(token: str, *, expected_type: Literal["access", "refresh"]) -> TokenPayload:
"""Décode ET valide. Lève jwt.InvalidTokenError sur signature/exp/type invalides."""
raw = jwt.decode(
token,
settings.jwt_secret.get_secret_value(),
algorithms=[ALGORITHM], # liste blanche explicite — voir le piège ci-dessous
)
payload = TokenPayload.model_validate(raw)
if payload.type != expected_type:
# Empêche de rejouer un refresh token là où on attend un access token.
raise jwt.InvalidTokenError(f"expected {expected_type} token, got {payload.type}")
return payload⚠️ Le piège
algorithmsqui a coûté des CVE entières. Toujours passeralgorithms=["HS256"]en liste blanche explicite. Si vous laissezpyjwtaccepternoneou si vous mélangez HS/RS, un attaquant peut forger un token signé avec votre clé publique RS256 interprétée comme un secret HS256. Liste blanche = une ligne, et c'est non négociable.
# ❌ La mauvaise façon
raw = jwt.decode(token, settings.jwt_secret.get_secret_value())
# pyjwt récent lève si algorithms manque, mais ne comptez pas dessus :
# soyez explicite. Et ne décodez JAMAIS sans vérifier (verify_signature=False)
# pour "juste lire le sub" — c'est la porte ouverte aux tokens forgés.La dépendance d'authentification
Voici le cœur FastAPI. OAuth2PasswordBearer n'est pas de la magie : c'est une dépendance qui lit l'en-tête Authorization, en extrait le Bearer, et — bonus — déclare le schéma de sécurité dans l'OpenAPI (le cadenas dans /docs).
# app/security/deps.py
from __future__ import annotations
from typing import Annotated
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, SecurityScopes
from app.security.tokens import decode_token
from app.users import CurrentUser, UserRepository, get_user_repository
# tokenUrl = l'endpoint où /docs ira chercher un token pour le bouton "Authorize".
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="auth/login",
scopes={
"agents:run": "Exécuter un agent LLM (coûteux).",
"agents:read": "Lire l'historique des conversations.",
"admin": "Administration.",
},
)
async def get_current_user(
security_scopes: SecurityScopes,
token: Annotated[str, Depends(oauth2_scheme)],
users: Annotated[UserRepository, Depends(get_user_repository)],
) -> CurrentUser:
# Un en-tête WWW-Authenticate correct aide les clients OAuth2 à réagir.
authenticate_value = (
f'Bearer scope="{security_scopes.scope_str}"' if security_scopes.scopes else "Bearer"
)
credentials_exc = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": authenticate_value},
)
try:
payload = decode_token(token, expected_type="access")
except jwt.InvalidTokenError:
raise credentials_exc from None
user = await users.get_by_id(payload.sub)
if user is None or not user.is_active:
raise credentials_exc
# AUTORISATION : l'utilisateur a-t-il TOUS les scopes requis par l'endpoint ?
for required in security_scopes.scopes:
if required not in payload.scopes:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, # 403, pas 401 : on SAIT qui tu es
detail=f"Missing required scope: {required}",
headers={"WWW-Authenticate": authenticate_value},
)
return CurrentUser(id=user.id, email=user.email, scopes=payload.scopes)Le modèle utilisateur et le repo, volontairement minimaux (en vrai : SQLAlchemy async, cf. leçon 03-async-io) :
# app/users.py
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(frozen=True, slots=True)
class CurrentUser:
id: str
email: str
scopes: list[str] = field(default_factory=list)
@dataclass
class UserRow:
id: str
email: str
password_hash: str
is_active: bool = True
class UserRepository:
"""En prod : requêtes async sur la DB. Ici : in-memory pour rester runnable."""
def __init__(self, rows: dict[str, UserRow]) -> None:
self._by_id = rows
self._by_email = {r.email: r for r in rows.values()}
async def get_by_id(self, user_id: str) -> UserRow | None:
return self._by_id.get(user_id)
async def get_by_email(self, email: str) -> UserRow | None:
return self._by_email.get(email)
# Singleton applicatif (remplacé en test via dependency_overrides).
_REPO = UserRepository({})
def get_user_repository() -> UserRepository:
return _REPOL'application : login, refresh, et un endpoint protégé qui appelle l'agent
C'est ici que tout se branche. Notez comment Security(get_current_user, scopes=[...]) exprime en une ligne « il faut être authentifié ET avoir ce scope ».
# app/main.py
from __future__ import annotations
from typing import Annotated
import jwt
from fastapi import Depends, FastAPI, HTTPException, Security, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel
from app.security.deps import get_current_user
from app.security.passwords import verify_against_dummy, verify_password
from app.security.tokens import create_access_token, create_refresh_token, decode_token
from app.users import CurrentUser, UserRepository, get_user_repository
app = FastAPI()
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer" # littéral attendu par le standard OAuth2
class RefreshRequest(BaseModel):
refresh_token: str
@app.post("/auth/login", response_model=TokenResponse)
async def login(
form: Annotated[OAuth2PasswordRequestForm, Depends()],
users: Annotated[UserRepository, Depends(get_user_repository)],
) -> TokenResponse:
# OAuth2PasswordRequestForm lit username/password en form-data (pas JSON) : c'est le standard.
user = await users.get_by_email(form.username)
if user is None:
verify_against_dummy() # égalise le timing → pas d'énumération de comptes
raise _invalid_credentials()
ok, _new_hash = verify_password(form.password, user.password_hash)
if not ok or not user.is_active:
raise _invalid_credentials()
# if _new_hash: await users.update_hash(user.id, _new_hash) # rehash transparent
# En vrai, on dériverait les scopes depuis les rôles de l'utilisateur.
scopes = ["agents:run", "agents:read"]
return TokenResponse(
access_token=create_access_token(user.id, scopes),
refresh_token=create_refresh_token(user.id),
)
@app.post("/auth/refresh", response_model=TokenResponse)
async def refresh(
body: RefreshRequest, # cf. note ci-dessous : en prod, plutôt un cookie httpOnly
users: Annotated[UserRepository, Depends(get_user_repository)],
) -> TokenResponse:
try:
payload = decode_token(body.refresh_token, expected_type="refresh")
except jwt.InvalidTokenError:
raise _invalid_credentials() from None
# PROD : vérifier ici que payload.jti est encore dans la table des refresh tokens
# actifs (sinon : token révoqué). Sinon la révocation est impossible.
user = await users.get_by_id(payload.sub)
if user is None or not user.is_active:
raise _invalid_credentials()
scopes = ["agents:run", "agents:read"]
return TokenResponse(
access_token=create_access_token(user.id, scopes),
refresh_token=create_refresh_token(user.id),
)
@app.get("/users/me", response_model=CurrentUser)
async def me(
user: Annotated[CurrentUser, Security(get_current_user)], # juste authentifié
) -> CurrentUser:
return user
def _invalid_credentials() -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)Note : ici le refresh token arrive dans un body Pydantic (simple et runnable). En production on le lit plutôt depuis un cookie httpOnly (
refresh_token: Annotated[str, Cookie()]), pas un body ni un header, pour qu'il ne soit jamais accessible au JavaScript du front — c'est la défense contre le vol de token par XSS.
Le lien avec l'agent Anthropic
Voici l'endpoint qui justifie tout ce dispositif. Servir un agent LLM coûte de l'argent réel (Claude Opus 4.8 est à 5/25 USD par million de tokens). Un endpoint d'agent doit être derrière auth + scope, et chaque appel doit être attribué à un user_id — pour le rate-limiting, le quota et la facturation.
# app/agents.py
from __future__ import annotations
from typing import Annotated
from anthropic import APIStatusError, AsyncAnthropic, RateLimitError
from fastapi import APIRouter, HTTPException, Security, status
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.security.deps import get_current_user
from app.users import CurrentUser
router = APIRouter(prefix="/agents", tags=["agents"])
# Une seule instance, réutilisée : le client gère son pool de connexions et les retries.
# La clé API est lue depuis ANTHROPIC_API_KEY (env) — JAMAIS exposée au client.
_anthropic = AsyncAnthropic(max_retries=3) # retries SDK sur 429/5xx avec backoff
class AgentRequest(BaseModel):
prompt: str
@router.post("/run")
async def run_agent(
body: AgentRequest,
# AUTH + SCOPE en une ligne. Sans le scope agents:run → 403, le LLM n'est jamais appelé.
user: Annotated[CurrentUser, Security(get_current_user, scopes=["agents:run"])],
) -> StreamingResponse:
async def token_stream():
try:
# On streame les tokens : indispensable pour les sorties longues
# (évite les timeouts HTTP) et pour une UX réactive (SSE/WebSocket).
async with _anthropic.messages.stream(
model="claude-opus-4-8",
max_tokens=4096,
thinking={"type": "adaptive"}, # pensée adaptative — jamais budget_tokens
messages=[{"role": "user", "content": body.prompt}],
metadata={"user_id": user.id}, # attribution → rate-limit & facturation
) as stream:
async for text in stream.text_stream:
yield text
except RateLimitError:
# Le quota Anthropic est saturé. On le remonte proprement au client.
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "LLM rate limited") from None
except APIStatusError as exc:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, f"LLM error {exc.status_code}") from exc
return StreamingResponse(token_stream(), media_type="text/plain")Le point d'architecture senior : la frontière de confiance. La clé API Anthropic et le secret JWT vivent côté serveur, dans l'environnement (cf. leçon 01-settings). Le client ne voit jamais que son propre access token. Si demain vous passez à des agents managés ou à des outils, c'est votre backend authentifié qui orchestre — le navigateur ne parle jamais directement à l'API Anthropic avec une clé.
⚙️ En production
Modes de défaillance.
- Révocation impossible par nature. Un access token valide reste valide jusqu'à
exp, même après un « logout » ou un bannissement. Trois parades, par ordre de coût : (1) TTL court (15 min) → la fenêtre de dégât est bornée ; (2) dénylist desjtirévoqués dans Redis avec un TTL = durée restante du token, vérifiée dansget_current_user; (3) tokens opaques (un id aléatoire stocké en DB) au lieu de JWT — révocables instantanément mais nécessitent un hit DB/Redis par requête. Le choix dépend de votre tolérance : un JWT pur est rapide mais non-révocable ; un token opaque est révocable mais stateful. La plupart des seniors font un compromis : access JWT court + refresh opaque révocable. - Horloges désynchronisées.
exp/iatsont des timestamps. Si vos serveurs ne sont pas en NTP, un token « du futur » est rejeté. Toujours travailler en UTC (datetime.now(UTC)), jamais en local. - Fuite du secret HS256. Si
jwt_secretfuite (commit Git, log, variable d'env exposée), tout token devient forgeable. C'est game over total. Stockez-le dans un secrets manager, faites-le tourner, et préférez RS256 quand plusieurs services sont en jeu (voir plus bas).
Performance. get_current_user s'exécute sur chaque requête protégée. La vérification de signature JWT est O(microsecondes) — négligeable. Le coût caché, c'est le users.get_by_id : un aller-retour DB par requête. En charge, cachez l'utilisateur (Redis, TTL court) ou faites confiance aux claims du token pour les données stables (email, rôles au moment de l'émission) en acceptant qu'elles puissent être légèrement périmées. Ne resignez pas, ne re-hashez pas à chaque requête : ces opérations sont lentes par conception et n'ont leur place qu'au /login.
Sécurité.
- HS256 vs RS256. HS256 : un seul secret qui sert à émettre et vérifier. Parfait pour un monolithe. RS256 : clé privée pour émettre (uniquement le service d'auth), clé publique pour vérifier (tous les autres services). Dès que vous avez plusieurs services, passez à RS256 — un service compromis qui ne détient que la clé publique ne peut pas forger de tokens.
- Stockage côté client.
localStorageest lisible par tout JS → vulnérable au XSS. Un cookiehttpOnly+Secure+SameSite=Strictprotège du XSS mais expose au CSRF (à mitiger par un token anti-CSRF ouSameSite). Il n'y a pas de réponse parfaite ; le consensus 2026 : access token en mémoire (jamais persisté), refresh token en cookie httpOnly. - HTTPS obligatoire. Un Bearer en clair sur le réseau = un attaquant qui le rejoue. TLS partout, sans exception.
- Toujours liste blanche
algorithms. Répété parce que c'est la faille n°1.
Observabilité. Loguez les événements d'auth (login réussi/échoué, refresh, 401/403) avec le user_id et l'IP, mais jamais le token ni le mot de passe. Un pic de 401 = attaque par bourrage d'identifiants. Un 403 sur agents:run = un client mal provisionné ou une tentative d'escalade. Exposez ces métriques (cf. leçon 02-observability). Pour les appels LLM, propagez le user.id dans le metadata Anthropic et dans vos traces, pour relier coût LLM ↔ utilisateur.
Les arbitrages senior. Le débat éternel : JWT stateless vs session stateful. Le JWT vend du « pas de DB à la vérification » ; en vrai, dès que vous voulez révoquer, vous réintroduisez un état (dénylist). Beaucoup d'équipes seniors concluent que pour une API mono-service, une bonne vieille session opaque en Redis est plus simple, révocable, et tout aussi rapide — et que le JWT ne brille vraiment que dans le multi-service / multi-domaine. Ne câblez pas du JWT par réflexe : choisissez selon votre besoin de révocation et votre topologie.
🏋️ Exercices
Exercice 1 — Implémenter le flux complet
Objectif. Faire tourner le code ci-dessus de bout en bout : seed un utilisateur (avec hash_password), obtenez un token via POST /auth/login (form-data !), appelez GET /users/me avec le Bearer, vérifiez que sans token vous recevez 401.
Indice/Solution. Avec httpx.AsyncClient (cf. leçon 03-testing) : await client.post("/auth/login", data={"username": ..., "password": ...}) — data=, pas json=, car OAuth2PasswordRequestForm lit du form-urlencoded. Récupérez access_token, puis headers={"Authorization": f"Bearer {access_token}"} sur l'appel à /users/me. Un appel sans header doit renvoyer 401 avec un en-tête WWW-Authenticate.
Exercice 2 — La barrière de scope
Objectif. Rendez POST /agents/run accessible uniquement avec le scope agents:run. Créez deux utilisateurs : l'un avec le scope, l'autre sans. Prouvez que le premier passe (200/stream) et le second reçoit 403 — et que l'API Anthropic n'est jamais appelée pour le second.
Indice/Solution. Security(get_current_user, scopes=["agents:run"]). Pour prouver que le LLM n'est pas appelé, mockez _anthropic.messages.stream et vérifiez assert mock.call_count == 0 sur le cas 403. La leçon : l'autorisation court-circuite avant le handler, donc avant tout coût LLM.
Exercice 3 — Révocation via dénylist Redis
Objectif. Ajoutez POST /auth/logout qui place le jti du token courant dans une dénylist Redis avec un TTL égal au temps restant avant exp. Modifiez get_current_user pour rejeter tout token dont le jti est dénylisté (401).
Indice/Solution. ttl = payload.exp - datetime.now(UTC), puis await redis.set(f"denylist:{payload.jti}", "1", ex=int(ttl.total_seconds())). Dans get_current_user, après décodage : if await redis.exists(f"denylist:{payload.jti}"): raise credentials_exc. Le TTL fait le ménage automatiquement : pas de fuite mémoire. C'est le compromis stateful minimal qui rend le JWT révocable.
Exercice 4 — Casser puis réparer : le timing attack
Objectif. Mesurez le temps de réponse de /auth/login pour un user inexistant vs un user existant avec mauvais mot de passe. Constatez l'écart si vous retirez verify_against_dummy(). Puis remettez-le et montrez que l'écart disparaît.
Indice/Solution. time.perf_counter() autour de 1000 appels de chaque cas, comparez les médianes. Sans la parade, « user inexistant » répond en microsecondes (court-circuit) tandis que « mauvais mot de passe » paie le coût d'argon2 (~50-100 ms). L'écart trahit l'existence du compte. verify_against_dummy() force le calcul d'un hash factice → temps égalisé. C'est subtil et invisible en revue de code : d'où l'intérêt de le tester.
Exercice 5 — Migrer HS256 → RS256
Objectif. Passez la signature de HS256 (secret partagé) à RS256 (paire de clés). Générez une paire RSA, signez avec la clé privée, vérifiez avec la publique. Simulez un second service qui ne possède que la clé publique : il doit pouvoir vérifier mais pas émettre.
Indice/Solution. jwt.encode(payload, private_key, algorithm="RS256") / jwt.decode(token, public_key, algorithms=["RS256"]). Générez les clés via cryptography (rsa.generate_private_key(...)). Le test qui prouve la séparation : tentez jwt.encode(payload, public_key, algorithm="RS256") → ça lève. Un service avec la seule clé publique est cantonné à la lecture — c'est tout l'intérêt en architecture multi-service.
Exercice 6 — Refresh token rotation + détection de rejeu
Objectif. Implémentez la rotation : chaque /auth/refresh invalide l'ancien refresh token et en émet un nouveau. Si un ancien refresh token (déjà utilisé) est rejoué, c'est le signe d'un vol → révoquez toute la famille de tokens de cet utilisateur.
Indice/Solution. Stockez les refresh tokens actifs par jti en DB, liés à un family_id. À chaque refresh : vérifiez que le jti présenté est actif, marquez-le consommé, émettez un nouveau token dans la même famille. Si un jti déjà consommé revient : un attaquant et l'utilisateur légitime utilisent tous deux la chaîne → invalidez toute la family_id. C'est le pattern de détection de vol de refresh token utilisé par Auth0/Okta. La difficulté est la gestion des courses (deux refresh quasi-simultanés du client légitime) : tolérez une petite fenêtre de grâce ou sérialisez par user.
🎤 En entretien
« Quelle est la différence entre authentification et autorisation, et comment FastAPI les exprime-t-il ? » L'authentification (« qui es-tu ? », → 401) et l'autorisation (« as-tu le droit ? », → 403) sont deux étapes distinctes ; en FastAPI, get_current_user fait la première et Security(..., scopes=[...]) ajoute la seconde dans la même dépendance, en court-circuitant le handler avant tout traitement coûteux.
« Un JWT, c'est chiffré ? Et comment révoquer un JWT ? » Non, un JWT est signé (intégrité) mais lisible par tous — ne jamais y mettre de secret ; et comme il est auto-porteur, on ne peut pas le révoquer nativement : on borne le dégât avec un TTL court + un refresh token, et si la révocation immédiate est requise on ajoute une dénylist de jti (ou on passe à des tokens opaques stateful).
« Pourquoi algorithms=["HS256"] explicite, et pourquoi pas SHA-256 pour les mots de passe ? » La liste blanche d'algorithmes empêche l'attaque de confusion d'algorithme (none, ou clé publique RS rejouée comme secret HS) ; et SHA-256 est trop rapide et non salé — pour les mots de passe il faut une fonction lente et gourmande en mémoire comme argon2, conçue pour rendre le brute-force GPU économiquement absurde.
« Comment sécuriseriez-vous un endpoint qui appelle un LLM payant ? » Auth obligatoire + un scope dédié (agents:run) qui bloque en 403 avant tout appel LLM, attribution de chaque requête à un user_id (via le metadata Anthropic et les traces) pour le rate-limiting/quota/facturation, clé API et secret JWT confinés côté serveur dans un secrets manager, et streaming des tokens pour éviter les timeouts — le client ne parle jamais directement à l'API du fournisseur.