La gestion des identités et des accès constitue l'un des défis architecturaux les plus complexes des systèmes modernes. Trois protocoles dominent le paysage de l'authentification et de l'autorisation en 2026 : OAuth 2.0, spécification d'autorisation déléguée publiée en 2012 (RFC.
La gestion des identités et des accès constitue l'un des défis architecturaux les plus complexes des systèmes modernes. Trois protocoles dominent le paysage de l'authentification et de l'autorisation en 2026 : OAuth 2.0, spécification d'autorisation déléguée publiée en 2012 (RFC 6749) et depuis enrichie de nombreuses extensions (PKCE, DPoP, RAR, PAR) ; OpenID Connect (OIDC), couche d'identité construite sur OAuth 2.0 qui standardise l'authentification via des tokens JWT signés ; et SAML 2.0 (Security Assertion Markup Language), vénérable standard XML publié en 2005 et omniprésent dans les environnements d'entreprise via Active Directory Federation Services et les solutions SSO legacy. Ces trois protocoles répondent à des besoins différents, présentent des architectures de sécurité distinctes, et leur confusion — extrêmement courante même parmi les développeurs expérimentés — génère des vulnérabilités critiques. Ce guide compare en profondeur leurs mécanismes, leurs failles de sécurité documentées, leurs cas d'usage optimaux, et détaille l'implémentation sécurisée avec Keycloak, Azure AD/Entra ID et ADFS, incluant la migration depuis SAML vers OpenID Connect pour les organisations cherchant à moderniser leur infrastructure d'identité.
1. OAuth 2.0 : délégation d'autorisation
OAuth 2.0 résout un problème précis : comment permettre à une application tierce (le client) d'accéder à des ressources protégées appartenant à un utilisateur (le resource owner), sans que l'utilisateur ait à partager ses credentials avec l'application tierce ?
Les quatre rôles OAuth 2.0 :
- Resource Owner : l'utilisateur final qui possède les ressources
- Client : l'application qui demande l'accès (web app, mobile app, API)
- Authorization Server : le serveur qui émet les tokens (Keycloak, Azure AD, Okta)
- Resource Server : l'API qui héberge les ressources protégées
2. Le flux Authorization Code : analyse détaillée
Le flux Authorization Code est le flux OAuth 2.0 recommandé pour les applications web avec un backend serveur. C'est le flux le plus sécurisé car le token n'est jamais exposé au navigateur.
Flux Authorization Code (sans PKCE) :
1. L'utilisateur clique "Se connecter avec Google"
Client → Navigateur : Redirection vers Authorization Server
2. Requête d'autorisation :
GET https://auth.example.com/oauth2/authorize?
response_type=code ← Demander un code d'autorisation
&client_id=my_app_id ← Identifiant de l'application
&redirect_uri=https://app.com/callback ← URI de retour
&scope=openid profile email ← Permissions demandées
&state=random_csrf_token ← Protection CSRF
&nonce=random_nonce_value ← Protection replay (OpenID Connect)
3. L'utilisateur s'authentifie et consent
4. Authorization Server → Navigateur : Redirection vers redirect_uri
GET https://app.com/callback?
code=AUTH_CODE_xyz123 ← Code d'autorisation (valide ~30 secondes)
&state=random_csrf_token
5. Backend vérifie le state et échange le code contre des tokens
POST https://auth.example.com/oauth2/token
grant_type=authorization_code
&code=AUTH_CODE_xyz123
&redirect_uri=https://app.com/callback
&client_id=my_app_id
&client_secret=SECRET ← Authentification du client (secret connu du backend)
6. Authorization Server répond :
{
"access_token": "eyJ...", ← Token d'accès (opaque ou JWT)
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhp...", ← Token de renouvellement (longue durée)
"id_token": "eyJ..." ← OpenID Connect uniquement : JWT avec identité
}
7. Le client utilise l'access_token pour appeler l'API :
GET https://api.example.com/userinfo
Authorization: Bearer eyJ...
3. PKCE : sécurisation des clients publics
PKCE (Proof Key for Code Exchange, RFC 7636, prononcé "pixy") est une extension d'OAuth 2.0 qui protège le flux Authorization Code contre les attaques d'interception du code d'autorisation. Il est désormais obligatoire pour tous les clients (RFC 9700, publié en 2024, remplace RFC 6749 pour les meilleures pratiques).
#!/usr/bin/env python3
"""
Implémentation PKCE OAuth 2.0 avec Python
"""
import secrets
import hashlib
import base64
import urllib.parse
import urllib.request
import json
def generate_pkce_pair():
"""Génère une paire code_verifier / code_challenge pour PKCE."""
# code_verifier : 43-128 caractères aléatoires
code_verifier = secrets.token_urlsafe(64) # 86 caractères URL-safe
# code_challenge = BASE64URL(SHA256(code_verifier))
digest = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b'=').decode()
return code_verifier, code_challenge
def build_authorization_url(
auth_endpoint: str,
client_id: str,
redirect_uri: str,
scope: str,
code_challenge: str
) -> tuple[str, str]:
"""Construit l'URL d'autorisation avec PKCE et state."""
state = secrets.token_urlsafe(32)
params = {
'response_type': 'code',
'client_id': client_id,
'redirect_uri': redirect_uri,
'scope': scope,
'state': state,
'code_challenge': code_challenge,
'code_challenge_method': 'S256', # Toujours S256, jamais plain
'nonce': secrets.token_urlsafe(16) # Pour OpenID Connect
}
url = f"{auth_endpoint}?{urllib.parse.urlencode(params)}"
return url, state
def exchange_code_for_tokens(
token_endpoint: str,
code: str,
code_verifier: str,
client_id: str,
redirect_uri: str
) -> dict:
"""Échange le code d'autorisation contre des tokens via PKCE."""
data = urllib.parse.urlencode({
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': redirect_uri,
'client_id': client_id,
'code_verifier': code_verifier # Preuve que ce client a initié la requête
# NB: Pas de client_secret pour les clients publics (SPA, mobile)
# Les clients confidentiels utilisent client_secret OU assertion JWT
}).encode()
req = urllib.request.Request(
token_endpoint,
data=data,
headers={'Content-Type': 'application/x-www-form-urlencoded'},
method='POST'
)
with urllib.request.urlopen(req) as response:
return json.loads(response.read())
# Exemple d'utilisation
code_verifier, code_challenge = generate_pkce_pair()
print(f"code_verifier: {code_verifier}")
print(f"code_challenge: {code_challenge}")
auth_url, state = build_authorization_url(
auth_endpoint='https://auth.example.com/oauth2/authorize',
client_id='my-spa-client',
redirect_uri='https://app.example.com/callback',
scope='openid profile email',
code_challenge=code_challenge
)
print(f"\nURL d'autorisation:\n{auth_url}")
# Stocker code_verifier et state en session (sessionStorage, pas localStorage)
4. Client Credentials Flow : M2M (Machine to Machine)
Le flux Client Credentials est conçu pour les communications entre services (M2M) où il n'y a pas d'utilisateur final impliqué. Le client s'authentifie directement auprès de l'Authorization Server avec son propre client_id et client_secret.
import httpx
import time
from functools import lru_cache
class OAuthClientCredentials:
"""Client OAuth 2.0 avec Client Credentials flow et renouvellement automatique."""
def __init__(self, token_endpoint: str, client_id: str, client_secret: str, scope: str):
self.token_endpoint = token_endpoint
self.client_id = client_id
self.client_secret = client_secret
self.scope = scope
self._access_token = None
self._token_expiry = 0
def _is_token_valid(self) -> bool:
"""Vérifie si le token actuel est encore valide (avec marge de 30 secondes)."""
return self._access_token is not None and time.time() < (self._token_expiry - 30)
def get_access_token(self) -> str:
"""Retourne un token valide, en en demandant un nouveau si nécessaire."""
if self._is_token_valid():
return self._access_token
response = httpx.post(
self.token_endpoint,
data={
'grant_type': 'client_credentials',
'client_id': self.client_id,
'client_secret': self.client_secret,
'scope': self.scope
},
timeout=10.0
)
response.raise_for_status()
token_data = response.json()
self._access_token = token_data['access_token']
self._token_expiry = time.time() + token_data.get('expires_in', 3600)
return self._access_token
def call_api(self, url: str) -> dict:
"""Appelle une API protégée avec renouvellement automatique du token."""
token = self.get_access_token()
response = httpx.get(
url,
headers={'Authorization': f'Bearer {token}'},
timeout=30.0
)
response.raise_for_status()
return response.json()
# Usage
client = OAuthClientCredentials(
token_endpoint='https://auth.example.com/oauth2/token',
client_id='service-account-id',
client_secret='service-account-secret',
scope='api.read api.write'
)
data = client.call_api('https://api.example.com/internal/data')
5. Device Authorization Flow : appareils sans navigateur
"""
Device Authorization Flow (RFC 8628)
Pour les appareils sans navigateur (Smart TV, CLI tools, IoT)
"""
import httpx
import time
def device_auth_flow(
device_endpoint: str,
token_endpoint: str,
client_id: str,
scope: str
):
# Étape 1 : Demander un device_code
response = httpx.post(device_endpoint, data={
'client_id': client_id,
'scope': scope
})
device_data = response.json()
device_code = device_data['device_code']
user_code = device_data['user_code']
verification_uri = device_data['verification_uri']
expires_in = device_data['expires_in']
interval = device_data.get('interval', 5)
# Étape 2 : Afficher les instructions à l'utilisateur
print(f"\n{'='*50}")
print(f"Pour vous connecter, rendez-vous sur :")
print(f" {verification_uri}")
print(f"Et entrez le code : {user_code}")
print(f"{'='*50}\n")
print(f"Ce code expire dans {expires_in} secondes.")
# Étape 3 : Polling jusqu'à ce que l'utilisateur s'authentifie
deadline = time.time() + expires_in
while time.time() < deadline:
time.sleep(interval)
token_response = httpx.post(token_endpoint, data={
'grant_type': 'urn:ietf:params:oauth:grant-type:device_code',
'device_code': device_code,
'client_id': client_id
})
token_data = token_response.json()
if token_response.status_code == 200:
print(f"[+] Authentification réussie !")
return token_data
error = token_data.get('error')
if error == 'authorization_pending':
print(".", end='', flush=True) # Toujours en attente
elif error == 'slow_down':
interval += 5 # Augmenter l'intervalle si le serveur demande
elif error == 'access_denied':
raise Exception("L'utilisateur a refusé l'accès")
elif error == 'expired_token':
raise Exception("Le code a expiré, relancer le processus")
raise Exception("Timeout : l'utilisateur n'a pas complété l'authentification")
6. OpenID Connect : authentification sur OAuth 2.0
OpenID Connect (OIDC) est une couche d'identité standardisée construite au-dessus d'OAuth 2.0. Son apport principal est l'ID Token, un JSON Web Token (JWT) signé qui prouve l'identité de l'utilisateur et contient des claims standardisés.
7. Structure d'un ID Token JWT
// JWT = BASE64URL(Header).BASE64URL(Payload).Signature
// Header
{
"alg": "RS256", // Algorithme de signature (RS256, ES256 recommandés)
"typ": "JWT",
"kid": "key-id-123" // Key ID pour récupérer la clé publique du JWKS
}
// Payload (Claims OpenID Connect)
{
// Claims obligatoires
"iss": "https://auth.example.com", // Issuer — qui a émis ce token
"sub": "user-uuid-abc123", // Subject — identifiant unique de l'utilisateur
"aud": "my-app-client-id", // Audience — pour qui ce token est destiné
"exp": 1735689600, // Expiration timestamp (Unix)
"iat": 1735686000, // Issued At timestamp
// Claims OpenID Connect standard
"nonce": "random_nonce_value", // Anti-replay (doit correspondre à la requête)
"auth_time": 1735685900, // Timestamp de l'authentification réelle
"acr": "urn:mace:incommon:iap:silver", // Authentication Context Reference (MFA level)
"amr": ["pwd", "otp"], // Authentication Methods References
// Claims de profil (scope=profile)
"name": "Jean Dupont",
"given_name": "Jean",
"family_name": "Dupont",
"preferred_username": "jean.dupont",
"locale": "fr-FR",
// Claims email (scope=email)
"email": "jean.dupont@example.com",
"email_verified": true,
// Claims personnalisés
"roles": ["admin", "editor"],
"department": "IT Security",
"tenant_id": "company-abc"
}
// Signature : RSA-SHA256 ou ECDSA-SHA256 sur Header.Payload
8. Validation correcte d'un ID Token JWT
"""
Validation complète d'un ID Token OpenID Connect
Chaque étape est OBLIGATOIRE — une seule omission = vulnérabilité critique
"""
import httpx
import jwt # PyJWT
from jwt.algorithms import RSAAlgorithm
import json
import time
class OIDCTokenValidator:
def __init__(self, issuer: str, client_id: str):
self.issuer = issuer
self.client_id = client_id
self._jwks_cache = None
self._jwks_cache_time = 0
self.JWKS_CACHE_TTL = 3600 # 1 heure
def _get_jwks(self) -> dict:
"""Récupère les clés publiques du serveur OIDC avec cache."""
if self._jwks_cache and (time.time() - self._jwks_cache_time) < self.JWKS_CACHE_TTL:
return self._jwks_cache
# Découverte via le endpoint well-known
discovery_url = f"{self.issuer}/.well-known/openid-configuration"
discovery = httpx.get(discovery_url, timeout=10).json()
jwks_uri = discovery['jwks_uri']
jwks = httpx.get(jwks_uri, timeout=10).json()
self._jwks_cache = jwks
self._jwks_cache_time = time.time()
return jwks
def validate_id_token(
self,
id_token: str,
nonce: str | None = None,
access_token: str | None = None
) -> dict:
"""
Valide un ID Token selon la spécification OpenID Connect Section 3.1.3.7.
Lève une exception si la validation échoue.
"""
# Étape 1 : Récupérer le header non vérifié pour obtenir le kid
header = jwt.get_unverified_header(id_token)
kid = header.get('kid')
alg = header.get('alg', 'RS256')
# Étape 2 : REJETER les algorithmes faibles ou None
if alg == 'none' or alg.startswith('HS'):
# HS256 signifie signature symétrique avec le client_secret
# JAMAIS acceptable pour un ID Token public
raise ValueError(f"Algorithme de signature non accepté : {alg}")
# Étape 3 : Récupérer la clé publique correspondante
jwks = self._get_jwks()
public_key = None
for key in jwks['keys']:
if key.get('kid') == kid:
public_key = RSAAlgorithm.from_jwk(json.dumps(key))
break
if not public_key:
raise ValueError(f"Clé publique introuvable pour kid={kid}")
# Étape 4 : Vérifier la signature ET les claims standards
# PyJWT vérifie automatiquement exp, iat, nbf si leeway=0
try:
payload = jwt.decode(
id_token,
public_key,
algorithms=[alg],
# Validation de l'audience (CRITIQUE — prévient les token confusion attacks)
audience=self.client_id,
# Validation de l'issuer (CRITIQUE)
issuer=self.issuer,
# Tolérance d'horloge (max 60 secondes)
leeway=60
)
except jwt.ExpiredSignatureError:
raise ValueError("Token expiré")
except jwt.InvalidAudienceError:
raise ValueError("Token destiné à un autre client (audience invalide)")
except jwt.InvalidIssuerError:
raise ValueError("Token émis par un serveur non autorisé")
except jwt.InvalidSignatureError:
raise ValueError("Signature JWT invalide — token falsifié ?")
# Étape 5 : Valider le nonce (anti-replay)
if nonce is not None:
token_nonce = payload.get('nonce')
if token_nonce != nonce:
raise ValueError(f"Nonce invalide : attendu {nonce}, reçu {token_nonce}")
# Étape 6 : Valider at_hash si access_token fourni (OIDC Section 3.1.3.6)
if access_token and 'at_hash' in payload:
import hashlib
import base64
# at_hash = BASE64URL(SHA256(access_token)[0:half_length])
hash_bytes = hashlib.sha256(access_token.encode()).digest()
half = len(hash_bytes) // 2
expected_at_hash = base64.urlsafe_b64encode(hash_bytes[:half]).rstrip(b'=').decode()
if payload['at_hash'] != expected_at_hash:
raise ValueError("at_hash invalide — access_token et id_token incohérents")
# Étape 7 : Vérifier auth_time si max_age a été demandé
# (implémentation selon les besoins de l'application)
return payload
def validate_access_token(self, access_token: str) -> dict:
"""
Validation d'un Access Token JWT (si le serveur utilise des JWT opaques, utiliser introspection).
Les Access Tokens ne sont PAS des ID Tokens — leur validation peut différer.
"""
# Pour les Access Tokens JWT : même processus mais l'audience est le Resource Server
# Pour les Access Tokens opaques : utiliser le token introspection endpoint (RFC 7662)
response = httpx.post(
f"{self.issuer}/oauth2/introspect",
data={'token': access_token},
auth=(self.client_id, 'client_secret'), # Authentification du Resource Server
timeout=10
)
data = response.json()
if not data.get('active', False):
raise ValueError("Token inactif ou expiré")
return data
9. SAML 2.0 : SSO d'entreprise par assertions XML
SAML 2.0 (Security Assertion Markup Language) est un standard OASIS publié en 2005 pour l'échange d'informations d'authentification et d'autorisation entre un Identity Provider (IdP) et un Service Provider (SP). Il repose sur des assertions XML signées numériquement plutôt que sur des tokens JWT.
<!-- Exemple d'assertion SAML 2.0 simplifiée -->
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_response_id_abc123"
Version="2.0"
IssueInstant="2026-05-01T10:00:00Z"
Destination="https://sp.example.com/saml/acs"
InResponseTo="_request_id_xyz789">
<saml:Issuer>https://idp.company.com</saml:Issuer>
<!-- Signature XML de la réponse -->
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<!-- ... RSA-SHA256 signature sur le contenu canonicalisé ... -->
</ds:Signature>
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</samlp:Status>
<saml:Assertion ID="_assertion_id_def456" Version="2.0"
IssueInstant="2026-05-01T10:00:00Z">
<saml:Issuer>https://idp.company.com</saml:Issuer>
<!-- Sujet : l'utilisateur authentifié -->
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">
jean.dupont@company.com
</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData
InResponseTo="_request_id_xyz789"
Recipient="https://sp.example.com/saml/acs"
NotOnOrAfter="2026-05-01T10:05:00Z"/> <!-- Validité de 5 minutes -->
</saml:SubjectConfirmation>
</saml:Subject>
<!-- Conditions de validité -->
<saml:Conditions NotBefore="2026-05-01T09:59:55Z"
NotOnOrAfter="2026-05-01T10:05:00Z">
<saml:AudienceRestriction>
<saml:Audience>https://sp.example.com</saml:Audience> <!-- CRITIQUE -->
</saml:AudienceRestriction>
</saml:Conditions>
<!-- Attributs de l'utilisateur -->
<saml:AttributeStatement>
<saml:Attribute Name="email">
<saml:AttributeValue>jean.dupont@company.com</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="groups">
<saml:AttributeValue>Domain Admins</saml:AttributeValue>
<saml:AttributeValue>IT Security</saml:AttributeValue>
</saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
</samlp:Response>
10. Vulnérabilités de sécurité SAML
SAML présente plusieurs vulnérabilités de sécurité historiques liées à la complexité de la validation XML :
"""
Vulnérabilités SAML courantes et leur détection.
À des fins d'audit uniquement.
"""
class SAMLVulnerabilities:
"""
Catalogue des vulnérabilités SAML pour l'audit de sécurité.
"""
@staticmethod
def check_signature_wrapping():
"""
XML Signature Wrapping (XSW) — CVE classique SAML.
Principe :
La signature SAML couvre un nœud XML spécifique (référencé par ID).
Un attaquant insère une assertion malveillante AUTOUR de l'assertion légitime
signée, de sorte que :
- La vérification de signature réussit (signature sur l'assertion originale)
- Mais l'application traite l'assertion malveillante
Exemple d'attaque XSW (simplifié) :
"""
attack_example = """
<samlp:Response>
<!-- Assertion malveillante (non signée, avec admin@example.com) -->
<saml:Assertion ID="_malicious">
<saml:NameID>admin@example.com</saml:NameID>
</saml:Assertion>
<!-- Signature légitime (couvre _legitimate, pas _malicious) -->
<ds:Signature>
<ds:Reference URI="#_legitimate"/>
<!-- signature valide... -->
</ds:Signature>
<!-- Assertion légitime (signée, avec user@example.com) -->
<saml:Assertion ID="_legitimate">
<saml:NameID>user@example.com</saml:NameID>
</saml:Assertion>
</samlp:Response>
Si le SP extrait le PREMIER nœud Assertion (mauvaise pratique)
→ Il récupère admin@example.com AVEC une signature "valide"
"""
mitigation = """
Mitigation :
1. Toujours vérifier que l'URI de référence de la signature correspond
EXACTEMENT à l'ID de l'assertion utilisée
2. Rejeter les réponses contenant plusieurs assertions non attendues
3. Utiliser des bibliothèques SAML éprouvées (OneLogin python3-saml,
Shibboleth, SimpleSAMLphp) avec des options anti-XSW activées
4. python3-saml : valider strict_mode=True et security options
"""
return attack_example, mitigation
@staticmethod
def check_comment_injection():
"""
CVE-2017-11427, CVE-2018-0489 : Comment Injection dans SAML.
L'attribut NameID vulnérable :
admin@example.comevil@example.com
Certains parseurs XML suppriment les commentaires AVANT la vérification
de signature, d'autres APRÈS → le résultat lu peut différer.
Résultat selon le parseur :
- Parseur A (supprime avant sig) : signature sur "admin@example.coma@example.com"
- Parseur B (supprime après sig) : lit "admin@example.com" → élévation de privilèges !
"""
pass
# Validation SAML sécurisée avec python3-saml
from onelogin.saml2.auth import OneLogin_Saml2_Auth
from onelogin.saml2.settings import OneLogin_Saml2_Settings
def validate_saml_response(request_data: dict, saml_settings: dict) -> dict:
"""
Validation SAML sécurisée avec toutes les vérifications activées.
"""
# Préparer les données de requête
req = {
'https': 'on',
'http_host': request_data['host'],
'script_name': '/saml/acs',
'get_data': {},
'post_data': {'SAMLResponse': request_data['saml_response']}
}
# Configuration de sécurité stricte
settings = {
"strict": True, # OBLIGATOIRE en production
"debug": False,
"security": {
"nameIdEncrypted": True, # NameID chiffré (recommandé)
"authnRequestsSigned": True, # Requêtes d'auth signées
"wantMessagesSigned": True, # Assertions signées obligatoires
"wantAssertionsSigned": True, # Chaque assertion doit être signée
"wantNameIdEncrypted": True, # NameID chiffré dans l'assertion
"signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
"digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256",
"rejectDeprecatedAlgorithm": True # Rejeter SHA1, MD5
},
"idp": saml_settings['idp'],
"sp": saml_settings['sp']
}
auth = OneLogin_Saml2_Auth(req, settings)
auth.process_response()
errors = auth.get_errors()
if errors:
raise ValueError(f"Erreur validation SAML : {errors} — {auth.get_last_error_reason()}")
if not auth.is_authenticated():
raise ValueError("Authentification SAML échouée")
return {
'name_id': auth.get_nameid(),
'attributes': auth.get_attributes(),
'session_index': auth.get_session_index()
}
11. Vulnérabilités OAuth 2.0 / OpenID Connect
Les implémentations OAuth 2.0 présentent de nombreuses vulnérabilités documentées dans le Top 10 des problèmes OAuth de l'OWASP :
Vulnérabilités OAuth 2.0/OIDC classées par risque :
1. CSRF sur le flux d'autorisation [CRITIQUE]
Description : Absence ou mauvaise validation du paramètre "state"
Impact : Un attaquant peut lier son propre compte externe au compte victime
Test : Intercepter la requête de callback, observer si state est validé
Correction : state obligatoire, validé côté serveur, valeur aléatoire imprévisible
2. Open Redirect via redirect_uri non validée [CRITIQUE]
Description : L'Authorization Server accepte des redirect_uri non enregistrées
Impact : Vol du code d'autorisation vers un serveur attaquant
CVE : CVE-2014-8127, de nombreuses implémentations encore affectées en 2026
Test : Modifier redirect_uri vers attacker.com
Correction : Validation exacte (pas de wildcard) du redirect_uri enregistré
3. Authorization Code Interception [HAUTE]
Description : Code d'autorisation intercepté en transit (PKCE absent)
Impact : Échange du code par l'attaquant si client_secret est absent (app mobile)
Correction : PKCE obligatoire pour TOUS les clients (RFC 9700)
4. Token Leakage via Referrer Header [HAUTE]
Description : Access/Refresh token dans l'URL (fragment ou query) → exposé dans Referer
Impact : Vol de tokens via logs, proxy, analytics
Correction : Utiliser uniquement Authorization Code (jamais Implicit flow)
5. JWT Algorithm Confusion [CRITIQUE]
Description : Serveur accepte alg=none ou alg=HS256 avec la clé publique RS256 comme HMAC
Impact : Forge de tokens sans connaître la clé privée
CVE : Affecte de nombreuses bibliothèques (cve.mitre.org)
Test : Modifier l'en-tête JWT : {"alg":"none"} ou {"alg":"HS256"} + signature vide
Correction : Whitelist stricte des algorithmes acceptés côté validation
6. Insufficient Audience Validation [HAUTE]
Description : Resource Server accepte des tokens destinés à un autre service
Impact : Token d'un service A utilisé pour accéder au service B (confused deputy)
Correction : Valider que "aud" contient l'identifiant du Resource Server actuel
7. Token Theft via XSS [HAUTE]
Description : Tokens stockés en localStorage accessibles via JavaScript malveillant
Correction : Stocker les tokens en httpOnly cookies ou memory (pas localStorage)
"""
Test de vulnérabilités OAuth 2.0 — Pour audits autorisés uniquement
"""
import requests
import json
from urllib.parse import urlparse, parse_qs
def test_open_redirect(auth_endpoint: str, client_id: str) -> bool:
"""Test si le serveur accepte des redirect_uri non enregistrées."""
malicious_redirect = "https://attacker.com/steal_token"
response = requests.get(
auth_endpoint,
params={
'response_type': 'code',
'client_id': client_id,
'redirect_uri': malicious_redirect,
'scope': 'openid',
'state': 'test_state'
},
allow_redirects=False
)
if response.status_code == 302:
location = response.headers.get('Location', '')
if malicious_redirect in location:
print(f"[VULN] Open Redirect : le serveur redirige vers {malicious_redirect}")
return True
print("[OK] Redirect URI non enregistrée rejetée")
return False
def test_state_csrf(callback_url: str, valid_code: str) -> bool:
"""Test si le callback valide le paramètre state."""
# Tenter d'utiliser un code valide avec un state incorrect
response = requests.get(
callback_url,
params={
'code': valid_code,
'state': 'invalid_attacker_state' # State non correspondant à la session
}
)
if response.status_code == 200 and 'error' not in response.url:
print("[VULN] CSRF : state non validé par le callback !")
return True
print("[OK] State validé correctement")
return False
def test_jwt_algorithm_none(id_token: str) -> bool:
"""Test de l'attaque JWT alg=none."""
import base64
# Décoder le token sans vérification
parts = id_token.split('.')
if len(parts) != 3:
return False
# Modifier l'en-tête pour alg=none
modified_header = base64.urlsafe_b64encode(
json.dumps({"alg": "none", "typ": "JWT"}).encode()
).rstrip(b'=').decode()
# Supprimer la signature
malicious_token = f"{modified_header}.{parts[1]}."
print(f"[TEST] Token modifié avec alg=none :")
print(f" {malicious_token[:50]}...")
# En pratique, tester si ce token est accepté par l'API
# response = requests.get(api_endpoint, headers={"Authorization": f"Bearer {malicious_token}"})
return malicious_token
12. Comparaison OAuth2 vs OIDC vs SAML
| Critère | OAuth 2.0 | OpenID Connect | SAML 2.0 |
|---|---|---|---|
| Objectif principal | Autorisation déléguée | Authentification | SSO + Fédération d'identité |
| Format des tokens | Opaque ou JWT | JWT (ID Token) | Assertions XML signées |
| Transport | HTTP/JSON | HTTP/JSON | HTTP + XML encodé Base64 |
| Cas d'usage API | Excellent | Excellent | Mauvais (overhead XML) |
| Cas d'usage SSO Enterprise | Bon | Bon | Excellent (standard établi) |
| Mobile/SPA | Excellent (PKCE) | Excellent (PKCE) | Problématique |
| Complexité d'implémentation | Moyenne | Moyenne | Élevée |
| Écosystème bibliothèques | Très riche | Très riche | Moyen |
| Prise en charge Microsoft AD | Via Entra ID | Via Entra ID | Native (ADFS) |
| Révocation de session | Via refresh_token | Via backchannel logout | Via SLO (Single Logout) |
| Algorithmes recommandés 2026 | RS256, ES256 | RS256, ES256 | RSA-SHA256, AES-256-CBC |
| Tendance adoption | Dominant (API) | Croissante (remplace SAML) | Déclinante (legacy) |
13. Keycloak : configuration sécurisée
Keycloak est la solution IAM open source de référence pour les organisations cherchant une solution on-premise. Il supporte OAuth 2.0, OpenID Connect et SAML 2.0.
# Déploiement Keycloak avec Docker (production)
docker run -d --name keycloak \
-p 8443:8443 \
-e KEYCLOAK_ADMIN=admin \
-e KEYCLOAK_ADMIN_PASSWORD='CHANGE_THIS_SECURE_PASSWORD' \
-e KC_HTTPS_CERTIFICATE_FILE=/opt/keycloak/conf/tls.crt \
-e KC_HTTPS_CERTIFICATE_KEY_FILE=/opt/keycloak/conf/tls.key \
-e KC_DB=postgres \
-e KC_DB_URL=jdbc:postgresql://postgres:5432/keycloak \
-e KC_DB_USERNAME=keycloak \
-e KC_DB_PASSWORD=db_password \
quay.io/keycloak/keycloak:24.0.0 start
# Endpoints Keycloak standard
# Discovery OIDC : https://keycloak.example.com/realms/{realm}/.well-known/openid-configuration
# JWKS : https://keycloak.example.com/realms/{realm}/protocol/openid-connect/certs
# Token : https://keycloak.example.com/realms/{realm}/protocol/openid-connect/token
# Authorize : https://keycloak.example.com/realms/{realm}/protocol/openid-connect/auth
// Configuration client Keycloak sécurisée (API REST Keycloak)
{
"clientId": "my-secure-app",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false, // Client confidentiel (avec client_secret)
"standardFlowEnabled": true, // Authorization Code Flow
"implicitFlowEnabled": false, // DÉSACTIVÉ — déprécié
"directAccessGrantsEnabled": false, // DÉSACTIVÉ — Resource Owner Password Credentials
"serviceAccountsEnabled": false, // Désactivé si non M2M
"authorizationServicesEnabled": false,
// PKCE obligatoire
"attributes": {
"pkce.code.challenge.method": "S256",
"require.pushed.authorization.requests": "false",
"oauth2.device.authorization.grant.enabled": "false",
"access.token.lifespan": "300", // 5 minutes max pour l'access token
"client.session.idle.timeout": "1800", // 30 minutes d'inactivité
"client.session.max.lifespan": "86400" // 24 heures max
},
// Redirect URIs exactes (pas de wildcards)
"redirectUris": [
"https://app.example.com/callback",
"https://app.example.com/silent-renew"
],
// Origins autorisées pour CORS
"webOrigins": [
"https://app.example.com"
],
// Scopes disponibles
"defaultClientScopes": ["openid", "profile", "email"],
"optionalClientScopes": ["roles", "address"]
}
14. Azure AD / Entra ID : configuration et sécurité
# Configuration Azure AD avec Azure CLI
az login
# Créer un App Registration
az ad app create \
--display-name "My Secure App" \
--web-redirect-uris "https://app.example.com/callback" \
--sign-in-audience "AzureADMyOrg" # Monoloc (plus sécurisé que MultiOrg)
# Récupérer l'App ID
APP_ID=$(az ad app list --display-name "My Secure App" --query '[0].appId' -o tsv)
# Configurer les claims optionnels (ajouter UPN, groups, etc.)
az ad app update --id $APP_ID --set optionalClaims='{
"idToken": [
{"name": "upn", "essential": false},
{"name": "groups", "essential": false}
],
"accessToken": [
{"name": "roles", "essential": false}
]
}'
# Créer un service principal pour M2M
az ad sp create --id $APP_ID
az ad app credential reset --id $APP_ID --credential-description "prod-secret"
"""
Authentification Azure AD / Entra ID avec MSAL Python
"""
from msal import ConfidentialClientApplication, PublicClientApplication
import json
class AzureADClient:
"""Client Entra ID pour les scénarios courants."""
def __init__(self, tenant_id: str, client_id: str, client_secret: str = None):
self.tenant_id = tenant_id
self.authority = f"https://login.microsoftonline.com/{tenant_id}"
if client_secret:
# Client confidentiel (backend, service)
self.app = ConfidentialClientApplication(
client_id,
authority=self.authority,
client_credential=client_secret,
# Validation stricte du tenant
validate_authority=True
)
else:
# Client public (desktop, mobile)
self.app = PublicClientApplication(
client_id,
authority=self.authority
)
def get_token_for_api(self, scopes: list[str]) -> str:
"""Client Credentials Flow pour les APIs M2M."""
result = self.app.acquire_token_for_client(scopes=scopes)
if 'access_token' in result:
return result['access_token']
else:
raise Exception(f"Erreur token Entra ID: {result.get('error_description')}")
def get_authorization_url(self, scopes: list[str], redirect_uri: str) -> tuple[str, str]:
"""Génère l'URL d'autorisation avec PKCE pour Authorization Code Flow."""
flow = self.app.initiate_auth_code_flow(
scopes=scopes,
redirect_uri=redirect_uri,
# state et nonce générés automatiquement par MSAL
)
return flow['auth_uri'], json.dumps(flow) # Stocker flow en session
def complete_authorization(self, flow_state: str, callback_params: dict) -> dict:
"""Complète le flux Authorization Code et retourne les tokens."""
flow = json.loads(flow_state)
result = self.app.acquire_token_by_auth_code_flow(flow, callback_params)
if 'error' in result:
raise Exception(f"Erreur auth: {result['error_description']}")
return {
'access_token': result.get('access_token'),
'id_token': result.get('id_token'),
'id_token_claims': result.get('id_token_claims'),
'expires_in': result.get('expires_in')
}
# Usage M2M
client = AzureADClient(
tenant_id='your-tenant-id',
client_id='your-client-id',
client_secret='your-client-secret'
)
token = client.get_token_for_api(['https://graph.microsoft.com/.default'])
15. ADFS vers Entra ID : guide de migration
La migration depuis ADFS (Active Directory Federation Services) vers Entra ID (ex-Azure AD) est l'une des migrations IAM les plus fréquentes en 2026. ADFS, basé sur SAML/WS-Federation, présente des coûts d'infrastructure élevés et une complexité opérationnelle croissante.
# Migration ADFS vers Entra ID — PowerShell
# 1. Inventaire des applications ADFS actuelles
Get-AdfsRelyingPartyTrust | Select-Object Name, Identifier, WsFedEndpoint, SAMLEndpoint | Export-Csv "adfs_apps.csv"
# 2. Identifier les applications compatibles Entra ID
# Critères : support OIDC ou SAML 2.0, pas de dépendances ADFS custom claims
Get-AdfsRelyingPartyTrust | Where-Object {$_.SAMLEndpoints.Count -gt 0} |
Select-Object Name, Identifier
# 3. Pour chaque application SAML : migration vers Entra ID
# Entra ID supporte nativement SAML 2.0 comme IdP
# Migration transparente pour les SP (Service Providers)
# 4. Configurer l'Entra ID comme IdP SAML de remplacement
# Portal Azure > Entra ID > Enterprise Applications > Nouvelle application > Non-gallery
# Upload du SP metadata XML
# 5. Phase de coexistence : Staged Rollout
# Certains utilisateurs → Entra ID, d'autres → ADFS (groupe AAD/ADFS)
Connect-MgGraph -Scopes "Policy.ReadWrite.AuthenticationFlows"
# Configurer le Feature Rollout Policy
# 6. Test de validation par application
# Utiliser Microsoft's AADSSOCheck script
Invoke-WebRequest -Uri "https://aka.ms/aadSSOcheck" -OutFile "AADSSOCheck.ps1"
.\AADSSOCheck.ps1
# 7. Validation des claims mappings
# Les claims ADFS (attributes) doivent être remappés dans Entra ID
# ADFS custom claim → Entra ID claim mapping via Graph API
# 8. Migration de la confiance de domaine
Set-MsolDomainAuthentication -DomainName "company.com" -Authentication Managed
# Après cette commande, ADFS n'est plus l'IdP pour ce domaine
# 9. Post-migration : monitorer les sign-ins dans Entra ID
# Entra ID Portal > Monitoring > Sign-ins
# Filtrer par "Authentication requirement: Legacy authentication"
16. DPoP : token binding pour la sécurité avancée
DPoP (Demonstrating Proof-of-Possession, RFC 9449) est une extension OAuth 2.0 qui lie les tokens à une clé cryptographique spécifique du client, rendant les tokens volés inutilisables par un attaquant.
"""
Implémentation DPoP (RFC 9449) — Proof-of-Possession Tokens
"""
import json
import time
import secrets
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
import jwt
import base64
import hashlib
class DPoPClient:
"""Client OAuth 2.0 avec support DPoP."""
def __init__(self):
# Générer une paire de clés éphémère EC P-256
self.private_key = ec.generate_private_key(ec.SECP256R1())
self.public_key = self.private_key.public_key()
# Exporter la clé publique en JWK
public_numbers = self.public_key.public_numbers()
self.jwk = {
"kty": "EC",
"crv": "P-256",
"x": base64.urlsafe_b64encode(
public_numbers.x.to_bytes(32, 'big')
).rstrip(b'=').decode(),
"y": base64.urlsafe_b64encode(
public_numbers.y.to_bytes(32, 'big')
).rstrip(b'=').decode()
}
def create_dpop_proof(self, http_method: str, http_uri: str,
access_token: str = None) -> str:
"""
Crée un DPoP proof JWT pour chaque requête.
Chaque requête nécessite un nouveau proof avec un jti unique.
"""
claims = {
"jti": secrets.token_urlsafe(16), # ID unique — anti-replay
"htm": http_method.upper(), # HTTP method
"htu": http_uri, # HTTP URI (sans query string)
"iat": int(time.time()), # Timestamp — le serveur rejette > 60s
}
# Si un access_token est fourni, inclure ath (access token hash)
if access_token:
ath = base64.urlsafe_b64encode(
hashlib.sha256(access_token.encode()).digest()
).rstrip(b'=').decode()
claims["ath"] = ath
# Créer le JWT avec la clé privée EC
# L'en-tête inclut la clé publique (jwk) pour que le serveur puisse vérifier
header = {
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": self.jwk
}
# PyJWT — encode avec en-tête personnalisé
proof = jwt.encode(
claims,
self.private_key,
algorithm="ES256",
headers=header
)
return proof
def request_with_dpop(self, session, url: str, method: str = "GET",
access_token: str = None, **kwargs):
"""Effectue une requête HTTP avec le DPoP proof header."""
import httpx
headers = kwargs.pop('headers', {})
# Créer le proof pour cette requête spécifique
proof = self.create_dpop_proof(method, url, access_token)
headers['DPoP'] = proof
if access_token:
headers['Authorization'] = f"DPoP {access_token}" # DPoP, pas Bearer !
return httpx.request(method, url, headers=headers, **kwargs)
17. SSO sécurisé : gestion des sessions et déconnexion
"""
Gestion sécurisée des sessions SSO et déconnexion
"""
from datetime import datetime, timedelta
import secrets
import httpx
class SSOSessionManager:
"""Gestionnaire de sessions SSO avec déconnexion propre."""
def __init__(self, oidc_config: dict):
self.issuer = oidc_config['issuer']
self.client_id = oidc_config['client_id']
self.client_secret = oidc_config['client_secret']
self.discovery = self._fetch_discovery()
def _fetch_discovery(self) -> dict:
"""Récupère la configuration OIDC via le endpoint well-known."""
url = f"{self.issuer}/.well-known/openid-configuration"
return httpx.get(url, timeout=10).json()
def logout_user(self, id_token_hint: str, post_logout_redirect: str) -> str:
"""
Déconnexion SSO propre via le End Session Endpoint (OIDC RP-Initiated Logout).
"""
end_session_endpoint = self.discovery.get('end_session_endpoint')
if not end_session_endpoint:
raise ValueError("Ce serveur OIDC ne supporte pas RP-Initiated Logout")
import urllib.parse
params = {
'id_token_hint': id_token_hint, # Identifier la session à terminer
'post_logout_redirect_uri': post_logout_redirect,
'client_id': self.client_id,
'state': secrets.token_urlsafe(32) # Anti-CSRF pour le redirect
}
logout_url = f"{end_session_endpoint}?{urllib.parse.urlencode(params)}"
return logout_url # Rediriger le navigateur vers cette URL
def revoke_token(self, token: str, token_type: str = "refresh_token") -> bool:
"""
Révocation explicite d'un token (RFC 7009).
Devrait toujours être appelé lors de la déconnexion.
"""
revocation_endpoint = self.discovery.get('revocation_endpoint')
if not revocation_endpoint:
return False # Serveur sans support de révocation
response = httpx.post(
revocation_endpoint,
data={
'token': token,
'token_type_hint': token_type,
'client_id': self.client_id,
'client_secret': self.client_secret
},
timeout=10
)
return response.status_code == 200
def setup_backchannel_logout(self, logout_token: str) -> str:
"""
Gère le Backchannel Logout (OIDC Core 1.0 — Back-Channel Logout).
Le serveur OIDC notifie directement l'application d'une déconnexion.
Utilisé quand le navigateur n'est plus accessible.
"""
# Valider le logout_token (similaire à ID Token mais typ=logout+jwt)
# Contient: iss, sub ou sid (session ID), iat, jti, events
# "events": {"http://schemas.openid.net/event/backchannel-logout": {}}
import jwt as pyjwt
# Validation du logout token
header = pyjwt.get_unverified_header(logout_token)
# ... même processus que la validation d'ID Token
payload = pyjwt.decode(
logout_token,
# clé publique du IdP
algorithms=['RS256'],
options={"verify_exp": True}
)
# Extraire le subject ou session ID à déconnecter
sub = payload.get('sub')
sid = payload.get('sid')
# Invalider la session correspondante dans le datastore
return sub or sid
18. Implémentation sécurisée côté frontend (SPA)
/**
* Gestion sécurisée OAuth 2.0 / OIDC dans une SPA (Single Page Application)
* Utilisation de la bibliothèque oidc-client-ts (recommandée par l'OIDC Foundation)
*/
import { UserManager, WebStorageStateStore, InMemoryWebStorage } from 'oidc-client-ts';
const oidcConfig = {
authority: 'https://auth.example.com/realms/production',
client_id: 'my-spa-client',
redirect_uri: 'https://app.example.com/callback',
post_logout_redirect_uri: 'https://app.example.com/',
response_type: 'code', // Authorization Code, JAMAIS token implicite
scope: 'openid profile email',
// PKCE activé par défaut dans oidc-client-ts v2+
// Stockage sécurisé des tokens
// OPTION 1 : Mémoire (plus sécurisé, perd les tokens au refresh de page)
userStore: new WebStorageStateStore({ store: new InMemoryWebStorage() }),
// OPTION 2 : sessionStorage (acceptable, pas partagé entre onglets)
// userStore: new WebStorageStateStore({ store: window.sessionStorage }),
// Ne jamais utiliser localStorage pour les tokens !
// Renouvellement silencieux
automaticSilentRenew: true,
silent_redirect_uri: 'https://app.example.com/silent-renew',
accessTokenExpiringNotificationTimeInSeconds: 60,
// Validation stricte
validateSubOnSilentRenew: true,
checkSessionIntervalInSeconds: 60,
};
const userManager = new UserManager(oidcConfig);
// Démarrer le flux d'authentification
async function login() {
await userManager.signinRedirect({
// Extradata pour la requête (ui_locales, etc.)
extraQueryParams: { ui_locales: 'fr' }
});
}
// Traiter le callback OAuth
async function handleCallback() {
try {
const user = await userManager.signinRedirectCallback();
console.log('Connecté:', user.profile.email);
// Nettoyer l'URL (supprimer le code d'autorisation)
window.history.replaceState({}, document.title, '/app');
return user;
} catch (error) {
console.error('Erreur callback OAuth:', error);
throw error;
}
}
// Appel API sécurisé avec le token
async function callSecureAPI(endpoint) {
const user = await userManager.getUser();
if (!user || user.expired) {
await login();
return;
}
const response = await fetch(endpoint, {
headers: {
'Authorization': `Bearer ${user.access_token}`,
'Content-Type': 'application/json'
}
});
if (response.status === 401) {
// Token expiré — tenter le renouvellement silencieux
try {
const renewedUser = await userManager.signinSilent();
return callSecureAPI(endpoint); // Réessayer avec le nouveau token
} catch {
await login(); // Forcer une reconnexion complète
}
}
return response.json();
}
// Déconnexion propre
async function logout() {
const user = await userManager.getUser();
if (user) {
await userManager.signoutRedirect({
id_token_hint: user.id_token // Permettre la déconnexion SSO propre
});
}
}
19. Sécurisation des tokens côté backend
"""
Middleware Django / FastAPI pour validation OAuth 2.0 / OIDC
"""
from fastapi import HTTPException, Security, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import httpx
import jwt
from jwt.algorithms import RSAAlgorithm
import json
import time
from functools import lru_cache
security = HTTPBearer()
# Cache des clés JWKS (rafraîchi toutes les heures)
_jwks_cache = {}
_jwks_cache_time = 0
def get_jwks(jwks_uri: str) -> dict:
global _jwks_cache, _jwks_cache_time
if time.time() - _jwks_cache_time > 3600:
_jwks_cache = httpx.get(jwks_uri, timeout=10).json()
_jwks_cache_time = time.time()
return _jwks_cache
def verify_token(
credentials: HTTPAuthorizationCredentials = Security(security),
required_scope: str = None
) -> dict:
"""
Middleware de validation des Access Tokens pour FastAPI.
Utilisé en Depends() sur les routes protégées.
"""
token = credentials.credentials
# 1. Récupérer l'en-tête sans validation (pour obtenir kid)
try:
header = jwt.get_unverified_header(token)
except jwt.DecodeError:
raise HTTPException(status_code=401, detail="Token malformé")
# 2. Refuser les algorithmes non sûrs
alg = header.get('alg', '')
if alg not in ('RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512'):
raise HTTPException(status_code=401, detail=f"Algorithme non autorisé: {alg}")
# 3. Récupérer la clé publique
jwks = get_jwks('https://auth.example.com/.well-known/jwks.json')
kid = header.get('kid')
key = None
for k in jwks['keys']:
if k.get('kid') == kid:
key = RSAAlgorithm.from_jwk(json.dumps(k))
break
if not key:
raise HTTPException(status_code=401, detail="Clé publique introuvable")
# 4. Valider le token
try:
payload = jwt.decode(
token,
key,
algorithms=[alg],
audience='https://api.example.com', # Audience = ce Resource Server
issuer='https://auth.example.com',
leeway=30
)
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expiré")
except jwt.InvalidAudienceError:
raise HTTPException(status_code=401, detail="Token non destiné à cette API")
except jwt.InvalidIssuerError:
raise HTTPException(status_code=401, detail="Issuer non autorisé")
except jwt.PyJWTError as e:
raise HTTPException(status_code=401, detail=f"Token invalide: {str(e)}")
# 5. Valider les scopes si nécessaire
if required_scope:
token_scopes = payload.get('scope', '').split()
if required_scope not in token_scopes:
raise HTTPException(
status_code=403,
detail=f"Scope insuffisant. Requis: {required_scope}"
)
return payload
# Utilisation dans FastAPI
from fastapi import FastAPI
app = FastAPI()
@app.get("/api/secure-data")
def get_secure_data(token_payload: dict = Depends(lambda c: verify_token(c, 'data.read'))):
"""Endpoint protégé nécessitant le scope data.read."""
return {
"user_id": token_payload['sub'],
"data": "données sensibles"
}
20. Tableau de sécurité : vulnérabilités et remédiations
| Vulnérabilité | Protocole | Impact | Détection | Remédiation |
|---|---|---|---|---|
| Open Redirect (redirect_uri) | OAuth2/OIDC | Vol de code | Test manuel/auto | Validation exacte, pas de wildcard |
| CSRF (state absent) | OAuth2/OIDC | Session hijacking | Nuclei template | State obligatoire, validé en session |
| JWT alg=none | OIDC/JWT | Forge de tokens | Test automatisé | Whitelist stricte d'algorithmes |
| aud non validée | OIDC | Confused deputy | Revue de code | Valider aud === Resource Server ID |
| XSW (XML Wrapping) | SAML | Usurpation d'identité | Test spécialisé | Bibliothèque SAML éprouvée + strict mode |
| Comment injection | SAML | Usurpation admin | CVE scan | Mise à jour des bibliothèques XML |
| Refresh token theft | OAuth2 | Accès persistant | Monitoring | Rotation + révocation automatique |
| Token in URL | OAuth2/OIDC | Fuite dans logs | Revue de code | Jamais de token en query string |
FAQ — Questions sur OAuth2, OIDC et SAML
Peut-on utiliser OAuth 2.0 seul pour l'authentification, sans OpenID Connect ?
C'est une erreur classique. OAuth 2.0 est un protocole d'autorisation, pas d'authentification. Un access_token prouve que l'application a le droit d'accéder à une ressource, mais ne prouve pas l'identité de l'utilisateur. L'endpoint /userinfo peut retourner des informations d'identité, mais sans les garanties cryptographiques de l'ID Token OIDC (signature, nonce anti-replay, iss/aud). Utiliser OAuth 2.0 pour l'authentification sans OIDC expose à des attaques de type confused deputy et d'injection de token. Toujours utiliser OpenID Connect (qui est OAuth 2.0 + ID Token standardisé) pour l'authentification.
Quelle est la différence entre l'Implicit Flow et l'Authorization Code Flow avec PKCE ?
L'Implicit Flow retournait directement l'access_token dans l'URL fragment après l'authentification (sans code intermédiaire), ce qui exposait le token aux logs du serveur, aux en-têtes Referer et à d'autres fuites. Il était conçu pour les SPAs sans backend. PKCE (Authorization Code avec code_verifier/code_challenge) résout les mêmes problèmes sans exposer de token dans l'URL, et fonctionne aussi bien sans client_secret. L'Implicit Flow est désormais officiellement déprécié par la RFC 9700 (2024) et doit être désactivé dans tous les Authorization Servers. PKCE est le remplacement obligatoire.
Comment gérer le renouvellement des tokens sans déconnecter l'utilisateur dans une SPA ?
Deux approches : (1) Renouvellement silencieux (silent renew) : une iframe cachée effectue le flux Authorization Code sur un silent_redirect_uri dédié, en profitant de la session active sur l'Authorization Server (cookie de session). La bibliothèque oidc-client-ts gère cela automatiquement via automaticSilentRenew: true. (2) Backend For Frontend (BFF) pattern : le backend gère les tokens (via httpOnly cookies), rafraîchit le refresh_token de manière transparente, et expose une API de session au frontend. Le pattern BFF est plus sécurisé car il supprime complètement les tokens du navigateur.
Dans quel cas choisir SAML plutôt qu'OpenID Connect en 2026 ?
SAML reste pertinent dans des cas spécifiques : (1) Applications d'entreprise legacy qui n'ont pas d'API REST et ne peuvent pas être modifiées, (2) Intégrations avec des solutions SaaS qui ne supportent pas OIDC (certains ERP, outils de facturation), (3) Environnements qui utilisent ADFS comme IdP central et ne peuvent pas migrer à court terme, (4) Exigences réglementaires spécifiques imposant SAML (rares). Dans tous les autres cas, OpenID Connect est préférable : bibliothèques plus récentes, meilleure sécurité (pas de XML Signature Wrapping), plus adapté aux architectures microservices et mobile.
Comment tester la sécurité d'une implémentation OAuth 2.0 lors d'un pentest web ?
La méthodologie de test OAuth inclut : (1) Mapping des flux — identifier tous les endpoints OAuth (authorize, token, revoke, introspect, userinfo), (2) Test du redirect_uri — essayer des valeurs non enregistrées, des wildcards et des encoded dots, (3) Test du state — intercepter le callback et modifier/supprimer le state, (4) Test des algorithmes JWT — modifier l'en-tête pour alg=none ou alg=HS256, (5) Test de la rotation des refresh tokens — utiliser deux fois le même refresh token, (6) Test de l'audience — essayer un access_token d'un service A sur le service B, (7) Énumération des scopes — demander des scopes non autorisés, (8) Token leakage — vérifier les logs HTTP, les en-têtes Referer. Des outils comme JWT_Tool (Python), Burp Suite avec le plugin AuthMatrix, et les templates Nuclei OAuth facilitent ces tests.
Comment implémenter le multi-tenant avec OAuth2/OIDC ?
Dans un contexte multi-tenant, plusieurs stratégies existent : (1) Un realm par tenant (Keycloak) — isolation totale, mais gestion administrative complexe. (2) Claim de tenant dans le token — un seul realm mais un claim tenant_id dans les tokens, l'application applique l'isolation applicative. (3) Entra ID multi-tenant apps — l'application est enregistrée avec signInAudience: AzureADMultipleOrgs, l'audience du token est validée par tenant ID. Le risque principal du multi-tenant est la tenant confusion attack : un utilisateur du tenant A utilise un token valide sur le tenant B. La validation doit toujours inclure la vérification du tenant_id dans les tokens.
Que sont les Pushed Authorization Requests (PAR) et pourquoi les utiliser ?
PAR (RFC 9126) est une extension OAuth 2.0 qui permet d'envoyer les paramètres d'autorisation directement au serveur (via un endpoint POST authentifié) avant de rediriger l'utilisateur. Le serveur retourne un request_uri court que l'on inclut dans la redirection. Avantages : (1) Les paramètres de la requête ne sont jamais exposés dans l'URL (ni dans les logs de navigation, ni dans Referer), (2) Le serveur peut valider les paramètres avant que l'utilisateur soit redirigé, (3) Résistance aux attaques de manipulation de requête. PAR est recommandé pour les applications à haute sécurité (banking, healthcare) et est souvent combiné avec RAR (Rich Authorization Requests, RFC 9396) pour des autorisations finement granulées.
Comment migrer une application de SAML vers OpenID Connect sans interruption de service ?
La migration se déroule en plusieurs phases : (1) Phase de préparation — configurer le serveur OIDC (Keycloak/Entra ID) comme nouveau IdP et mapper les attributs SAML vers les claims OIDC, (2) Phase de test — déployer une version de l'application avec support des deux protocoles (feature flag), tester avec un groupe restreint d'utilisateurs, (3) Phase de coexistence — les deux protocoles coexistent, les nouveaux utilisateurs utilisent OIDC, les sessions existantes continuent avec SAML jusqu'à expiration, (4) Bascule complète — désactiver le SAML côté application après validation complète, (5) Désactivation ADFS — une fois toutes les applications migrées, décommissionner ADFS. La durée typique est de 3 à 6 mois pour une application enterprise avec de nombreux utilisateurs.
Pour approfondir les aspects pratiques de l'identité en entreprise, consultez notre guide sur Active Directory et la sécurité Kerberos. Les architectures Zero Trust qui s'appuient sur OAuth2/OIDC sont détaillées dans notre article sur l'implémentation du Zero Trust. La sécurité des API REST qui consomment des tokens OAuth est couverte dans notre guide sur la sécurité des APIs REST et JWT. Pour les environnements cloud, notre analyse de la sécurité AWS IAM complète ce panorama. Enfin, la gestion des secrets et des credentials est approfondie dans notre article sur HashiCorp Vault en pratique.
Références : OWASP Testing Guide — OAuth Testing et RFC 9700 — OAuth 2.0 Security Best Current Practice.
21. Sécurité des tokens JWT avancée : JWE et rotation
La plupart des implémentations utilisent des JWT signés (JWS — JSON Web Signature) mais pas chiffrés. Pour les données sensibles dans les tokens (numéros de sécurité sociale, données médicales), le chiffrement des tokens via JWE (JSON Web Encryption) est nécessaire.
"""
JWE (JSON Web Encryption) — Tokens chiffrés pour données sensibles
Chiffrement RSA-OAEP + AES-256-GCM
"""
from Crypto.PublicKey import RSA
from Crypto.Cipher import AES, PKCS1_OAEP
from Crypto.Random import get_random_bytes
import base64
import json
import struct
class JWEHandler:
"""Création et déchiffrement de tokens JWE."""
def __init__(self, private_key_pem: str = None, public_key_pem: str = None):
if private_key_pem:
self.private_key = RSA.import_key(private_key_pem)
if public_key_pem:
self.public_key = RSA.import_key(public_key_pem)
@staticmethod
def _b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
@staticmethod
def _b64url_decode(data: str) -> bytes:
padding = 4 - len(data) % 4
return base64.urlsafe_b64decode(data + '=' * padding)
def encrypt(self, payload: dict) -> str:
"""
Chiffre un payload en JWE format compact.
Algorithme : RSA-OAEP-256 (key wrapping) + A256GCM (content)
"""
# 1. Générer une Content Encryption Key (CEK) aléatoire AES-256
cek = get_random_bytes(32) # 256 bits
# 2. Chiffrer la CEK avec la clé publique RSA (key wrapping)
rsa_cipher = PKCS1_OAEP.new(self.public_key)
encrypted_cek = rsa_cipher.encrypt(cek)
# 3. Générer un vecteur d'initialisation aléatoire (IV)
iv = get_random_bytes(12) # 96 bits pour GCM
# 4. Header JWE
header = {
"alg": "RSA-OAEP-256",
"enc": "A256GCM",
"typ": "JWE"
}
header_b64 = self._b64url_encode(json.dumps(header).encode())
# 5. Chiffrer le payload avec AES-256-GCM
# L'en-tête encodé est l'Additional Authenticated Data (AAD)
aad = header_b64.encode()
aes_cipher = AES.new(cek, AES.MODE_GCM, nonce=iv)
aes_cipher.update(aad)
payload_bytes = json.dumps(payload).encode()
ciphertext, auth_tag = aes_cipher.encrypt_and_digest(payload_bytes)
# 6. Assembler le JWE compact : header.encrypted_key.iv.ciphertext.tag
return '.'.join([
header_b64,
self._b64url_encode(encrypted_cek),
self._b64url_encode(iv),
self._b64url_encode(ciphertext),
self._b64url_encode(auth_tag)
])
def decrypt(self, jwe_token: str) -> dict:
"""Déchiffre et vérifie l'intégrité d'un token JWE."""
parts = jwe_token.split('.')
if len(parts) != 5:
raise ValueError("Format JWE invalide — 5 parties attendues")
header_b64, enc_key_b64, iv_b64, ciphertext_b64, tag_b64 = parts
# Décoder les composants
encrypted_cek = self._b64url_decode(enc_key_b64)
iv = self._b64url_decode(iv_b64)
ciphertext = self._b64url_decode(ciphertext_b64)
auth_tag = self._b64url_decode(tag_b64)
# Déchiffrer la CEK avec la clé privée RSA
rsa_cipher = PKCS1_OAEP.new(self.private_key)
cek = rsa_cipher.decrypt(encrypted_cek)
# Déchiffrer et vérifier avec AES-256-GCM
aes_cipher = AES.new(cek, AES.MODE_GCM, nonce=iv)
aes_cipher.update(header_b64.encode()) # AAD = header
try:
plaintext = aes_cipher.decrypt_and_verify(ciphertext, auth_tag)
except ValueError:
raise ValueError("Intégrité JWE compromise — tag d'authentification invalide")
return json.loads(plaintext)
# Exemple : Token JWE pour données médicales
jwe = JWEHandler(private_key_pem=open("private.pem").read(),
public_key_pem=open("public.pem").read())
sensitive_claims = {
"sub": "patient-uuid-123",
"iss": "https://health.example.com",
"exp": 1735689600,
"social_security_number": "1 85 12 34 567 890 12", # Données sensibles chiffrées
"diagnosis_code": "J18.9"
}
encrypted_token = jwe.encrypt(sensitive_claims)
# Seul le détenteur de la clé privée peut déchiffrer
decrypted = jwe.decrypt(encrypted_token)
22. Token introspection et gestion de la révocation
"""
Implémentation d'un serveur d'introspection de tokens (RFC 7662)
et de révocation de tokens (RFC 7009)
"""
from fastapi import FastAPI, HTTPException, Depends, Form
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from sqlalchemy import create_engine, Column, String, Boolean, DateTime
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
import datetime
import secrets
import hashlib
app = FastAPI()
security = HTTPBasic()
Base = declarative_base()
# Modèle de base de données pour les tokens émis
class IssuedToken(Base):
__tablename__ = "issued_tokens"
jti = Column(String, primary_key=True) # Token ID
client_id = Column(String, index=True)
sub = Column(String, index=True) # Sujet (user)
scope = Column(String)
issued_at = Column(DateTime)
expires_at = Column(DateTime)
revoked = Column(Boolean, default=False)
revocation_reason = Column(String, nullable=True)
refresh_token_hash = Column(String, nullable=True, index=True)
engine = create_engine("postgresql://user:pass@localhost/oauth")
Base.metadata.create_all(engine)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def verify_client(credentials: HTTPBasicCredentials = Depends(security)):
"""Vérifie les credentials du client OAuth (Basic Auth sur les endpoints de token)."""
# En production, utiliser une vraie vérification en DB
REGISTERED_CLIENTS = {
"my-api-server": "client-secret-hash",
}
if credentials.username not in REGISTERED_CLIENTS:
raise HTTPException(status_code=401, detail="Client non reconnu")
return credentials.username
@app.post("/oauth2/introspect")
async def introspect_token(
token: str = Form(...),
token_type_hint: str = Form(default="access_token"),
client_id: str = Depends(verify_client),
db = Depends(get_db)
):
"""
Token Introspection Endpoint (RFC 7662).
Permet aux Resource Servers de valider les Access Tokens opaques.
"""
# Calculer le hash du token pour la recherche en DB
token_hash = hashlib.sha256(token.encode()).hexdigest()
# Chercher le token dans la base
issued = db.query(IssuedToken).filter(
IssuedToken.jti == token_hash # ou join avec table des tokens
).first()
if not issued:
return {"active": False}
# Vérifier l'expiration et la révocation
now = datetime.datetime.utcnow()
if issued.revoked or issued.expires_at < now:
return {"active": False}
# Token actif — retourner les claims
return {
"active": True,
"client_id": issued.client_id,
"sub": issued.sub,
"scope": issued.scope,
"iat": int(issued.issued_at.timestamp()),
"exp": int(issued.expires_at.timestamp()),
"token_type": "Bearer"
}
@app.post("/oauth2/revoke")
async def revoke_token(
token: str = Form(...),
token_type_hint: str = Form(default="refresh_token"),
client_id: str = Depends(verify_client),
db = Depends(get_db)
):
"""
Token Revocation Endpoint (RFC 7009).
Révoque un access_token ou refresh_token.
"""
token_hash = hashlib.sha256(token.encode()).hexdigest()
issued = db.query(IssuedToken).filter(
IssuedToken.jti == token_hash
).first()
if issued and issued.client_id == client_id:
issued.revoked = True
issued.revocation_reason = "explicit_revocation"
db.commit()
# Si c'est un refresh_token, révoquer AUSSI tous les access_tokens associés
if token_type_hint == "refresh_token":
related_tokens = db.query(IssuedToken).filter(
IssuedToken.sub == issued.sub,
IssuedToken.client_id == client_id,
IssuedToken.revoked == False
).all()
for t in related_tokens:
t.revoked = True
t.revocation_reason = "parent_refresh_token_revoked"
db.commit()
# RFC 7009 : toujours retourner 200, même si le token n'existe pas
return {}
23. Federation d'identité inter-organisations
La fédération d'identité permet à des organisations distinctes de se faire mutuellement confiance pour l'authentification de leurs utilisateurs. C'est le fondement des collaborations B2B et des consortiums industriels.
# Configuration Keycloak Identity Brokering
# Permettre aux utilisateurs d'un partenaire de se connecter via leur propre IdP
# 1. Configurer un Identity Provider externe dans Keycloak
# Admin Console > Identity Providers > Add Provider > OIDC
# Via API REST Keycloak :
curl -X POST https://keycloak.company.com/admin/realms/production/identity-provider/instances \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"alias": "partner-company",
"displayName": "Partner Company SSO",
"providerId": "oidc",
"enabled": true,
"config": {
"clientId": "keycloak-company",
"clientSecret": "partner-shared-secret",
"authorizationUrl": "https://sso.partner.com/oauth2/authorize",
"tokenUrl": "https://sso.partner.com/oauth2/token",
"jwksUrl": "https://sso.partner.com/.well-known/jwks.json",
"issuer": "https://sso.partner.com",
"validateSignature": "true",
"useJwksUrl": "true",
"pkceEnabled": "true",
"pkceMethod": "S256"
}
}'
# 2. Mapper les attributs de l'IdP partenaire vers les claims locaux
# Mapper l'email de l'IdP partenaire vers l'email Keycloak
curl -X POST https://keycloak.company.com/admin/realms/production/identity-provider/instances/partner-company/mappers \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
"identityProviderAlias": "partner-company",
"name": "email-mapper",
"config": {
"claim": "email",
"user.attribute": "email",
"syncMode": "INHERIT"
}
}'
"""
Vérification de l'issuer lors de la fédération multi-IdP
Protection contre l'attaque d'issuer confusion
"""
from typing import Optional
import jwt
import httpx
class FederatedTokenValidator:
"""
Valide les tokens provenant de multiples Identity Providers fédérés.
Protection critique contre les attaques d'issuer confusion.
"""
# Map des issuers autorisés et de leurs configurations
TRUSTED_ISSUERS = {
"https://auth.company.com": {
"jwks_uri": "https://auth.company.com/.well-known/jwks.json",
"audience": "company-apps",
"algorithms": ["RS256", "ES256"]
},
"https://sso.partner.com": {
"jwks_uri": "https://sso.partner.com/.well-known/jwks.json",
"audience": "partner-integration",
"algorithms": ["RS256"]
},
# NE JAMAIS ajouter d'issuers inconnus ou dynamiques ici
}
def validate(self, token: str, required_audience: str) -> dict:
"""
Valide un token en s'assurant que l'issuer est dans la liste de confiance.
L'audience doit également correspondre à ce Resource Server spécifique.
"""
# Lecture non vérifiée pour obtenir l'issuer
try:
unverified = jwt.decode(token, options={"verify_signature": False})
except jwt.DecodeError:
raise ValueError("Token malformé")
issuer = unverified.get("iss")
if not issuer:
raise ValueError("Token sans issuer (iss) claim")
# Vérifier que l'issuer est dans notre liste de confiance
if issuer not in self.TRUSTED_ISSUERS:
raise ValueError(f"Issuer non autorisé : {issuer}")
config = self.TRUSTED_ISSUERS[issuer]
# Vérifier que l'audience correspond à ce service
token_audience = unverified.get("aud", [])
if isinstance(token_audience, str):
token_audience = [token_audience]
if required_audience not in token_audience:
raise ValueError(
f"Audience incorrecte: {token_audience} — attendu: {required_audience}")
# Récupérer les clés JWKS de l'issuer correct
jwks_client = jwt.PyJWKClient(config["jwks_uri"])
signing_key = jwks_client.get_signing_key_from_jwt(token)
# Validation finale avec la clé de l'issuer correct
payload = jwt.decode(
token,
signing_key.key,
algorithms=config["algorithms"],
audience=required_audience,
issuer=issuer
)
return payload
validator = FederatedTokenValidator()
24. Zero Trust et OAuth 2.0 : Rich Authorization Requests
Le standard RAR (Rich Authorization Requests, RFC 9396) est une extension OAuth 2.0 qui permet de transporter des informations d'autorisation finement granulées dans la requête d'autorisation, permettant des modèles de permission plus expressifs que les simples scopes.
"""
Rich Authorization Requests (RAR — RFC 9396)
Pour des autorisations très granulaires (banking, healthcare, légal)
"""
import json
import urllib.parse
def build_rar_authorization_request(
auth_endpoint: str,
client_id: str,
redirect_uri: str,
code_challenge: str
) -> str:
"""
Construit une requête d'autorisation avec RAR pour un accès bancaire granulaire.
Exemple : Autoriser un paiement de montant et destinataire spécifiques.
"""
# RAR : détails précis de l'autorisation demandée
authorization_details = [
{
"type": "payment_initiation", # Type d'autorisation standardisé
"locations": ["https://api.bank.com/payments"],
"instructedAmount": {
"currency": "EUR",
"amount": "123.50"
},
"creditorName": "Merchant XYZ",
"creditorAccount": {
"iban": "FR7612345678901234567890189"
},
"remittanceInformationUnstructured": "Invoice #2026-001"
}
]
params = {
"response_type": "code",
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": "openid", # Scope minimal — les détails sont dans RAR
"authorization_details": json.dumps(authorization_details),
"code_challenge": code_challenge,
"code_challenge_method": "S256",
"state": "random_state_value"
}
return f"{auth_endpoint}?{urllib.parse.urlencode(params)}"
# Le token résultant contiendra un claim "authorization_details"
# permettant à l'API de valider exactement ce qui a été autorisé
# (montant, destinataire, etc.) — aucune modification possible post-autorisation
# Exemple de claim dans l'access_token :
example_token_claims = {
"sub": "user-uuid-123",
"iss": "https://auth.bank.com",
"aud": "https://api.bank.com/payments",
"exp": 1735689600,
"authorization_details": [
{
"type": "payment_initiation",
"instructedAmount": {"currency": "EUR", "amount": "123.50"},
"creditorName": "Merchant XYZ",
# Ces champs sont LIÉS CRYPTOGRAPHIQUEMENT au token
# L'API refuse tout paiement avec un montant ou destinataire différent
}
]
}
25. Audit de sécurité d'une implémentation OAuth2 : checklist complète
#!/bin/bash
# Script d'audit OAuth2/OIDC automatisé
# Teste les mauvaises configurations courantes
TARGET="https://auth.example.com"
CLIENT_ID="test-client"
echo "=== AUDIT OAuth2/OIDC : $TARGET ==="
# 1. Découverte de la configuration
echo "[1] Configuration OIDC Discovery..."
DISCOVERY=$(curl -sk "$TARGET/.well-known/openid-configuration")
echo $DISCOVERY | python3 -c "import json, sys; d=json.load(sys.stdin); print(f' Issuer: {d.get(\"issuer\")}'); print(f' Response types: {d.get(\"response_types_supported\")}'); print(f' Grant types: {d.get(\"grant_types_supported\")}'); print(f' PKCE methods: {d.get(\"code_challenge_methods_supported\")}'); print(f' End session: {d.get(\"end_session_endpoint\",\"[ABSENT - RP-Initiated Logout non supporté]\")}')"
# 2. Vérifier que l'Implicit Flow est désactivé
echo "[2] Test Implicit Flow (doit être refusé)..."
IMPLICIT_RESP=$(curl -sk -o /dev/null -w "%{http_code}" \
"$TARGET/oauth2/authorize?response_type=token&client_id=$CLIENT_ID&redirect_uri=https://app.example.com/callback&scope=openid")
if echo $IMPLICIT_RESP | grep -q "302\|301"; then
echo " [ATTENTION] Implicit flow peut être accepté (code: $IMPLICIT_RESP)"
else
echo " [OK] Implicit flow refusé ou erreur"
fi
# 3. Vérifier PKCE obligatoire
echo "[3] Test Authorization Code SANS PKCE (doit être refusé si PKCE obligatoire)..."
NO_PKCE=$(curl -sk -o /dev/null -w "%{http_code}" \
"$TARGET/oauth2/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=https://app.example.com/callback&scope=openid&state=test")
echo " Code HTTP sans PKCE: $NO_PKCE"
# 4. Test algorithmes JWT acceptés
echo "[4] Test JWKS endpoint..."
JWKS=$(curl -sk "$TARGET/.well-known/jwks.json")
echo $JWKS | python3 -c "
import json, sys
d = json.load(sys.stdin)
for key in d.get('keys', []):
alg = key.get('alg', 'non spécifié')
kty = key.get('kty')
kid = key.get('kid', 'N/A')
print(f' Clé: kid={kid}, kty={kty}, alg={alg}')
if alg in ('HS256', 'HS384', 'HS512'):
print(' [ATTENTION] Algorithme symétrique dans JWKS public !')
"
# 5. Vérifier la sécurité des en-têtes HTTP
echo "[5] En-têtes de sécurité..."
HEADERS=$(curl -sI "$TARGET/.well-known/openid-configuration")
for header in "Strict-Transport-Security" "X-Content-Type-Options" "X-Frame-Options" "Content-Security-Policy"; do
if echo "$HEADERS" | grep -qi "$header"; then
echo " [OK] $header présent"
else
echo " [MANQUANT] $header absent"
fi
fi
echo "=== Audit terminé ==="
26. Monitoring et anomalie detection pour les flux OAuth2
"""
Détection d'anomalies dans les flux OAuth2/OIDC
Via analyse des logs d'autorisation
"""
from collections import defaultdict
from datetime import datetime, timedelta
import json
class OAuthAnomalyDetector:
"""
Détecte les comportements anormaux dans les flux OAuth2.
À intégrer dans le pipeline de logs du serveur d'autorisation.
"""
def __init__(self):
self.client_request_counts = defaultdict(list)
self.failed_auth_counts = defaultdict(list)
self.unusual_redirect_uris = set()
self.known_redirect_uris = {} # client_id -> set of known URIs
def analyze_authorization_request(self, event: dict):
"""Analyse une demande d'autorisation OAuth2."""
client_id = event.get('client_id')
redirect_uri = event.get('redirect_uri')
ip = event.get('ip_address')
timestamp = datetime.fromisoformat(event.get('timestamp', datetime.now().isoformat()))
# Anomalie 1 : Trop de requêtes depuis la même IP (credential stuffing)
self.client_request_counts[ip].append(timestamp)
recent_requests = [t for t in self.client_request_counts[ip]
if t > datetime.now() - timedelta(minutes=5)]
if len(recent_requests) > 50:
self._alert("RATE_LIMIT_BREACH",
f"IP {ip} — {len(recent_requests)} requêtes en 5 minutes")
# Anomalie 2 : Redirect URI jamais vue pour ce client
if client_id not in self.known_redirect_uris:
self.known_redirect_uris[client_id] = set()
if (redirect_uri and
redirect_uri not in self.known_redirect_uris.get(client_id, set())):
# Première fois qu'on voit ce redirect URI pour ce client
self.known_redirect_uris[client_id].add(redirect_uri)
if len(self.known_redirect_uris[client_id]) > 5:
self._alert("NEW_REDIRECT_URI",
f"Client {client_id} — Nouveau redirect_uri : {redirect_uri}")
def analyze_token_issuance(self, event: dict):
"""Analyse l'émission d'un token."""
sub = event.get('sub')
client_id = event.get('client_id')
country = event.get('geo_country')
device_fingerprint = event.get('device_fingerprint')
# Anomalie 3 : Connexion depuis un nouveau pays
# En production, stocker l'historique des pays de connexion par utilisateur
# Si le pays actuel n'est jamais apparu dans les 90 derniers jours → alerte
# Anomalie 4 : Rotation de refresh token non utilisée (token theft indicator)
# Si l'ancien refresh token est réutilisé après rotation → theft probable
if event.get('refresh_token_reused'):
self._alert("REFRESH_TOKEN_THEFT",
f"Utilisateur {sub} — Refresh token déjà utilisé (possible vol)",
severity="CRITICAL")
# Action immédiate : révoquer TOUTES les sessions de cet utilisateur
def _alert(self, alert_type: str, message: str, severity: str = "HIGH"):
"""Envoie une alerte au SIEM."""
alert = {
"type": alert_type,
"message": message,
"severity": severity,
"timestamp": datetime.now().isoformat()
}
print(f"[ALERT/{severity}] {alert_type}: {message}")
# En production : envoyer à Wazuh, Splunk, ElasticSearch...
detector = OAuthAnomalyDetector()
27. Comparaison des solutions IAM du marché
| Solution | Type | OAuth2 | OIDC | SAML | MFA | Prix (estimé) |
|---|---|---|---|---|---|---|
| Keycloak | Open Source | Oui | Oui | Oui | TOTP, WebAuthn | Gratuit + infra |
| Authentik | Open Source | Oui | Oui | Oui | TOTP, WebAuthn, SMS | Gratuit / Enterprise |
| Entra ID | SaaS Microsoft | Oui | Oui | Oui | Passkeys, SMS, Authenticator | ~6€/user/mois |
| Okta | SaaS | Oui | Oui | Oui | Complet | ~15€/user/mois |
| Auth0 | SaaS (Okta) | Oui | Oui | Oui | Complet | ~23€/1000 MAU |
| Casdoor | Open Source | Oui | Oui | Oui | TOTP | Gratuit |
| Zitadel | Open Source/SaaS | Oui | Oui | Partiel | TOTP, WebAuthn | Gratuit / Cloud |
| Ping Identity | Enterprise | Oui | Oui | Oui | Complet | Sur devis |
28. Passkeys et WebAuthn : l'avenir de l'authentification sans mot de passe
Les Passkeys représentent l'évolution majeure de l'authentification en 2025-2026, intégrant le standard WebAuthn (W3C) et FIDO2 dans les systèmes d'exploitation et navigateurs principaux. Cette technologie rend les authentifications phishing-résistantes en liant cryptographiquement les identifiants au domaine du site web, contrairement aux mots de passe qui peuvent être saisis sur des sites de phishing.
Techniquement, un Passkey est une paire de clés cryptographiques asymétriques (EC P-256 par défaut) créée lors de l'enregistrement. La clé privée est stockée de manière sécurisée dans le gestionnaire de mots de passe du système d'exploitation (iCloud Keychain, Google Password Manager, Windows Hello, gestionnaire de mots de passe tiers) et ne quitte jamais l'appareil. La clé publique est enregistrée sur le serveur. Lors de l'authentification, le serveur génère un challenge aléatoire que le client signe avec sa clé privée après que l'utilisateur a fourni un facteur local (biométrie, PIN) — aucun secret ne transite jamais sur le réseau.
L'intégration des Passkeys dans les flux OAuth2/OIDC est naturelle : l'Authorization Server implémente WebAuthn comme méthode d'authentification de premier facteur. Keycloak supporte les Passkeys depuis la version 22 via l'extension WebAuthn. Azure Entra ID propose les Passkeys (FIDO2 Security Keys et Passkeys géographiquement distribués) depuis 2023. La séquence d'authentification reste celle d'un flux Authorization Code standard, mais l'étape d'authentification de l'utilisateur (étape 3 du flux décrit précédemment) utilise WebAuthn plutôt qu'un formulaire de mot de passe traditionnel.
Pour les équipes de sécurité, la migration vers les Passkeys présente plusieurs défis opérationnels. Le premier est la gestion des comptes de récupération pour les utilisateurs qui perdent leurs appareils — une procédure de récupération robuste ne doit pas réintroduire un vecteur de phishing (comme une récupération par email). Le deuxième est la gestion des accès programmatiques (service accounts, scripts) qui ne peuvent pas utiliser de biométrie — pour ces cas, les credentials de type Client Credentials OAuth2 avec Client Assertions JWT signées par des certificats restent la bonne approche. Le troisième est la compatibilité des applications legacy qui ne supportent pas WebAuthn et nécessitent une couche de bridge.
29. Conditional Access et Zero Trust avec OAuth2
Le Conditional Access est un mécanisme qui évalue le contexte de chaque demande d'authentification (localisation géographique, état de conformité de l'appareil, risque de connexion calculé par l'analyse comportementale) pour décider d'accorder ou de refuser l'accès, ou d'exiger un facteur d'authentification supplémentaire. Il constitue la matérialisation du Zero Trust dans le flux OAuth2/OIDC.
Dans l'architecture Zero Trust, aucune connexion n'est automatiquement de confiance, même depuis le réseau interne d'entreprise. Chaque demande d'accès est évaluée en temps réel selon des signaux multiples. Azure Conditional Access (dans Entra ID) et Keycloak Authorization Services permettent de construire des politiques d'accès conditionnel sophistiquées. Une politique typique peut stipuler que l'accès aux applications de ressources humaines sensibles (données de paie, évaluations) n'est accordé que si : l'utilisateur est authentifié avec un Passkey ou une clé FIDO2 (MFA phishing-résistant), l'appareil est géré par la MDM de l'entreprise et conforme aux politiques de sécurité, la connexion provient d'une localisation géographique habituelle pour cet utilisateur, et le score de risque calculé par Microsoft Entra ID Protection (analyse des signaux de comportement anormal) est inférieur à un seuil défini.
L'implémentation du Conditional Access dans le flux OAuth2 se fait généralement via un mécanisme de Step-Up Authentication. Quand une application demande un access_token avec un scope sensible (par exemple hr:payroll:write), le Authorization Server peut retourner une réponse insufficient_user_authentication (RFC 9470) indiquant que l'authentification actuelle de l'utilisateur est insuffisante pour ce scope spécifique. L'application doit alors rediriger l'utilisateur vers une authentification renforcée (MFA supplémentaire, biométrie) avant de pouvoir obtenir le token requis.
La corrélation entre le Conditional Access et la détection d'anomalies dans les logs OAuth2 est une source précieuse de threat intelligence. Les tentatives d'authentification depuis des pays jamais visités, les connexions depuis des adresses IP de proxy ou TOR, les tentatives de renouvellement de token depuis des localisations différentes de l'émission originale, ou les patterns de credential stuffing (tentatives rapides sur plusieurs comptes) sont des signaux que les serveurs d'autorisation modernes analysent en temps réel pour ajuster dynamiquement les exigences d'authentification.
30. Gouvernance et cycle de vie des applications OAuth2 en entreprise
La gestion des applications OAuth2/OIDC enregistrées dans un Authorization Server d'entreprise nécessite une gouvernance formalisée pour éviter la prolifération de clients non maintenus, l'accumulation de scopes excessifs, et la dégradation progressive de la posture de sécurité. Cette gouvernance, souvent négligée dans la précipitation des déploiements, est pourtant un vecteur d'attaque réel : des applications legacy avec des client_secrets non renouvelés, des scopes trop larges jamais réduits, et des redirect_uri incluant des domaines expirés rachetés par des attaquants constituent des risques concrets.
Un inventaire formel de toutes les applications OAuth2 enregistrées est le premier prérequis. Cet inventaire doit inclure : l'identifiant et le nom de l'application, l'équipe responsable (RACI), la date d'enregistrement et la date de dernière révision, les scopes accordés avec leur justification, les redirect_uri enregistrées, la date d'expiration du client_secret (si applicable), et l'état d'activité (active en production, en développement, décommissionnée). Azure Entra ID et Keycloak exposent des APIs qui permettent de générer automatiquement cet inventaire, qui peut être enrichi dans un outil CMDB.
La rotation périodique des client_secrets est une exigence de sécurité fondamentale souvent négligée. Les client_secrets sont des credentials équivalents à des mots de passe et doivent être traités avec le même soin : stockage dans un gestionnaire de secrets (HashiCorp Vault, AWS Secrets Manager), rotation annuelle (ou trimestrielle pour les applications critiques), et procédure de rotation d'urgence en cas de compromission suspectée. La transition vers des Client Assertions JWT (RFC 7523) à la place des client_secrets fixes est une amélioration significative car les assertions JWT sont de courte durée (quelques minutes) et signées par la clé privée du client — la compromission d'une assertion n'est pas réutilisable par un attaquant.
La revue des scopes accordés à intervalles réguliers permet d'appliquer le principe du moindre privilège dans la durée. Des scopes accordés lors du développement initial pour faciliter les tests et jamais réduits, des applications qui demandent tous les scopes disponibles par précaution, ou des changements d'architecture qui ont rendu inutiles certains scopes : toutes ces situations accumulent des permissions excessives qui constituent une dette de sécurité. Un audit annuel des scopes utilisés versus les scopes accordés, en corrélant les logs d'utilisation avec les permissions, permet de détecter et de révoquer les permissions inutilisées.
La décommission des applications OAuth2 est un processus qui doit être formalisé. Quand une application est retirée du service, son enregistrement OAuth2 doit être désactivé puis supprimé, ses client_secrets révoqués, et tous les tokens existants invalidés. Une application décommissionnée mais laissée active dans l'Authorization Server avec un redirect_uri pointant vers un domaine expiré peut être détournée par un attaquant qui achète le domaine expiré pour intercepter des codes d'autorisation. Ce vecteur d'attaque, appelé "OAuth OAuth Redirect Attack via Expired Domain", a été documenté dans plusieurs programmes de bug bounty et mérite une vigilance particulière.
Conclusion et recommandations
La maîtrise de ces techniques et outils est indispensable pour tout professionnel de la cybersécurité en 2026. L'évolution constante des menaces exige une veille permanente et une mise à jour régulière des compétences. Pour aller plus loin, consultez nos articles techniques ou contactez notre équipe pour un accompagnement sur mesure adapté à votre contexte.
À retenir : La sécurité est un processus continu, pas un état. Chaque audit, chaque test et chaque analyse contribue à renforcer la posture de défense de l'organisation face aux menaces actuelles et futures.
À propos de l'auteur
Ayi NEDJIMI
Auditeur Senior Cybersécurité & Consultant IA
Expert Judiciaire — Cour d'Appel de Paris
Habilitation Confidentiel Défense
ayi@ayinedjimi-consultants.fr
Ayi NEDJIMI est un vétéran de la cybersécurité avec plus de 25 ans d'expérience sur des missions critiques. Ancien développeur Microsoft à Redmond sur le module GINA (Windows NT4) et co-auteur de la version française du guide de sécurité Windows NT4 pour la NSA.
À la tête d'Ayi NEDJIMI Consultants, il réalise des audits Lead Auditor ISO 42001 et ISO 27001, des pentests d'infrastructures critiques, du forensics et des missions de conformité NIS2 / AI Act.
Conférencier international (Europe & US), il a formé plus de 10 000 professionnels.
Domaines d'expertise
Ressources & Outils de l'auteur
Testez vos connaissances
Mini-quiz de certification lié à cet article — propulsé par CertifExpress
Articles connexes
PAM : Gestion Complète des Accès Privilégiés Entreprise
La gestion des accès privilégiés (PAM — Privileged Access Management) constitue l'un des contrôles de sécurité les plus critiques qu'une organisation peut déployer pour protéger ses actifs numériques les plus sensibles. Les comptes privilégiés — administrateurs de domaine, root Unix,...
Gestion des vulnérabilités DevSecOps : triage et remède
Policy as Code : OPA, Kyverno et gouvernance cloud
Commentaires (2)
Laisser un commentaire