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é.

Résumé en une phrase : OAuth 2.0 = autorisation (déléguer l'accès à des ressources) ; OpenID Connect = authentification (vérifier l'identité) ; SAML 2.0 = SSO d'entreprise (fédération d'identité XML/assertion). Ces trois protocoles ne sont pas interchangeables et répondent à des besoins complémentaires.

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èreOAuth 2.0OpenID ConnectSAML 2.0
Objectif principalAutorisation déléguéeAuthentificationSSO + Fédération d'identité
Format des tokensOpaque ou JWTJWT (ID Token)Assertions XML signées
TransportHTTP/JSONHTTP/JSONHTTP + XML encodé Base64
Cas d'usage APIExcellentExcellentMauvais (overhead XML)
Cas d'usage SSO EnterpriseBonBonExcellent (standard établi)
Mobile/SPAExcellent (PKCE)Excellent (PKCE)Problématique
Complexité d'implémentationMoyenneMoyenneÉlevée
Écosystème bibliothèquesTrès richeTrès richeMoyen
Prise en charge Microsoft ADVia Entra IDVia Entra IDNative (ADFS)
Révocation de sessionVia refresh_tokenVia backchannel logoutVia SLO (Single Logout)
Algorithmes recommandés 2026RS256, ES256RS256, ES256RSA-SHA256, AES-256-CBC
Tendance adoptionDominant (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)
Bonnes pratiques de sécurité des tokens : (1) Access tokens : durée de vie courte (5-15 minutes), ne jamais stocker en localStorage. (2) Refresh tokens : rotation obligatoire à chaque utilisation (RFC 6749 Section 10.4), révocation en cas de détection de réutilisation. (3) ID Tokens : ne jamais utiliser comme Access Token pour des APIs tierces. (4) Considérer DPoP ou mTLS pour les applications à haute sécurité (banking, healthcare). (5) Activer la détection d'anomalie de session (géolocalisation, fingerprint).

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éProtocoleImpactDétectionRemédiation
Open Redirect (redirect_uri)OAuth2/OIDCVol de codeTest manuel/autoValidation exacte, pas de wildcard
CSRF (state absent)OAuth2/OIDCSession hijackingNuclei templateState obligatoire, validé en session
JWT alg=noneOIDC/JWTForge de tokensTest automatiséWhitelist stricte d'algorithmes
aud non validéeOIDCConfused deputyRevue de codeValider aud === Resource Server ID
XSW (XML Wrapping)SAMLUsurpation d'identitéTest spécialiséBibliothèque SAML éprouvée + strict mode
Comment injectionSAMLUsurpation adminCVE scanMise à jour des bibliothèques XML
Refresh token theftOAuth2Accès persistantMonitoringRotation + révocation automatique
Token in URLOAuth2/OIDCFuite dans logsRevue de codeJamais de token en query string
Checklist de sécurité OAuth2/OIDC/SAML : (1) OAuth2 : PKCE obligatoire pour tous les clients, state validé, redirect_uri exacte, Implicit flow désactivé. (2) OIDC : valider iss, aud, exp, nonce ; rejeter alg=none et alg=HS256 ; utiliser les clés via JWKS. (3) SAML : bibliothèque à jour, strict mode, signature obligatoire sur les assertions et les réponses, valider NotBefore/NotOnOrAfter. (4) Général : rotation des refresh tokens, backchannel logout, monitoring des sessions suspectes (géo, user-agent), DPoP pour les APIs critiques.

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é ==="
Synthèse OAuth2/OIDC/SAML en 2026 : Le standard OAuth 2.0 évolue rapidement avec RFC 9700 (Security BCP), RFC 9449 (DPoP), RFC 9396 (RAR) et RFC 9126 (PAR). Pour les nouveaux projets : utiliser systématiquement OIDC avec PKCE, des access tokens de courte durée (5-15 min), DPoP ou mTLS pour les APIs critiques, et la signature des tokens avec ES256 (ECDSA) plutôt que RS256 pour la performance. SAML reste pertinent pour les intégrations legacy mais ne devrait plus être choisi pour les nouvelles intégrations. La migration SAML→OIDC est aujourd'hui mature et documentée.

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é

SolutionTypeOAuth2OIDCSAMLMFAPrix (estimé)
KeycloakOpen SourceOuiOuiOuiTOTP, WebAuthnGratuit + infra
AuthentikOpen SourceOuiOuiOuiTOTP, WebAuthn, SMSGratuit / Enterprise
Entra IDSaaS MicrosoftOuiOuiOuiPasskeys, SMS, Authenticator~6€/user/mois
OktaSaaSOuiOuiOuiComplet~15€/user/mois
Auth0SaaS (Okta)OuiOuiOuiComplet~23€/1000 MAU
CasdoorOpen SourceOuiOuiOuiTOTPGratuit
ZitadelOpen Source/SaaSOuiOuiPartielTOTP, WebAuthnGratuit / Cloud
Ping IdentityEnterpriseOuiOuiOuiCompletSur 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.