Aller au contenu
medium

OAuth 2.x : la délégation d'accès aux APIs

20 min de lecture

OAuth 2.x permet à une application d'accéder à vos données sans connaître votre mot de passe. C'est le standard utilisé quand une app demande "Se connecter avec Google" ou quand vos microservices communiquent entre eux. OAuth ne gère pas l'identité (c'est le rôle d'OIDC), il gère la délégation d'accès : "cette application peut lire mes fichiers, mais pas les supprimer".

Ce guide vous explique OAuth 2.x, le standard dominant pour sécuriser l'accès aux APIs. À la fin, vous saurez :

  • Identifier les 4 acteurs OAuth et leur rôle
  • Choisir le bon flow selon votre contexte (SPA, mobile, server-to-server)
  • Implémenter PKCE correctement (obligatoire depuis OAuth 2.1)
  • Gérer les tokens : durées de vie, rotation, révocation, introspection

Prérequis : avoir lu Les bases de l'IAM.

Prenez le cas d'une application de suivi de commits qui doit lire vos dépôts GitHub. Avant OAuth, la seule façon de lui donner accès était de lui confier vos identifiants, avec trois conséquences :

  • Donner votre mot de passe GitHub à l'application
  • Faire confiance à cette application pour ne pas l'utiliser malicieusement
  • Changer votre mot de passe pour révoquer l'accès (révoquant aussi tous les autres accès)

OAuth permet de déléguer un accès limité sans partager le mot de passe :

  • L'application obtient un token avec des permissions spécifiques (scopes)
  • Vous pouvez révoquer ce token sans toucher à votre mot de passe
  • L'application ne voit jamais vos credentials

La confusion la plus fréquente porte sur les deux serveurs. L'Authorization Server délivre les jetons et ne détient aucune donnée métier ; le Resource Server détient les données et ne sait pas authentifier un utilisateur, il se contente de valider le jeton qu'on lui présente. Cette séparation est ce qui permet à un même serveur d'autorisation de protéger des dizaines d'API différentes. Dans les déploiements réels, ces deux rôles sont souvent portés par des équipes distinctes, ce qui explique pourquoi les problèmes de scopes se découvrent tard.

ActeurRôleExemple
Resource OwnerL'entité qui possède les donnéesVous (votre compte GitHub)
ClientL'application qui veut accéder aux donnéesApplication de suivi de commits
Authorization ServerAuthentifie le Resource Owner, délivre les tokensKeycloak, Auth0, GitHub OAuth
Resource ServerAPI qui protège les données, valide les tokensAPI GitHub

Suivez les flèches dans l'ordre et repérez ce qui ne circule jamais : le mot de passe de l'utilisateur ne quitte pas l'Authorization Server, et le client ne le voit à aucun moment. Le second point à observer est que le client ne parle jamais directement au Resource Owner pour obtenir des droits : tout passe par une redirection du navigateur vers l'Authorization Server, ce qui est précisément la fenêtre que les attaques OAuth cherchent à exploiter.

Les 4 acteurs OAuth 2.0 : Resource Owner, Authorization Server, Client et Resource Server avec leurs interactions

Les scopes définissent quelles permissions le client demande. L'utilisateur (Resource Owner) peut les accepter ou les refuser.

Il n'existe aucune norme sur le nommage des scopes : chaque fournisseur invente le sien, et seuls les scopes OIDC (openid, profile, email) sont standardisés. Conséquence pratique, un scope valable chez un fournisseur n'a aucun sens chez un autre, et une erreur de nom se traduit par un jeton délivré avec moins de droits que prévu, sans message d'erreur explicite. Vérifiez toujours les noms exacts dans la documentation du fournisseur : GitHub, par exemple, expose repo, public_repo, repo:status et repo_deployment pour ses applications OAuth classiques, pas de couple lecture/écriture générique.

ServiceScopePermission accordée
GitHubrepoAccès complet aux dépôts privés et publics
GitHubpublic_repoAccès aux seuls dépôts publics
GitHubrepo:statusLecture et écriture des statuts de commit
GoogleprofileLire le profil public
GoogleemailLire l'adresse email
Keycloakoffline_accessObtenir un refresh token

Demandez uniquement les scopes nécessaires. L'argument n'est pas seulement l'hésitation de l'utilisateur au moment du consentement : un jeton porteur d'un scope large reste dangereux pendant toute sa durée de vie, et un vol de jeton donne alors accès à tout ce que le scope couvre. Un jeton limité à la lecture ne permet ni suppression ni modification, quelle que soit la façon dont il a fuité.

Le Redirect URI (ou callback URL) est l'URL vers laquelle l'Authorization Server redirige l'utilisateur après l'authentification.

Ces trois règles visent toutes le même scénario d'attaque : détourner le code d'autorisation au moment de la redirection. La première est la plus souvent contournée en environnement de développement, où l'on ajoute un caractère joker pour tester plusieurs ports. Ce raccourci finit régulièrement en production, où il suffit alors d'enregistrer un sous-domaine correspondant au motif pour recevoir les codes des utilisateurs. L'exception http://localhost est explicitement prévue par les spécifications, mais elle ne vaut que pour le développement local.

RèglePourquoi
Exact match obligatoireÉvite l'interception du code/token par un attaquant
Enregistré à l'avanceLe serveur ne redirige que vers les URIs connues
HTTPS obligatoireSauf http://localhost pour le développement

OAuth 2.0 définit plusieurs "flows" (ou "grants"). OAuth 2.1 en conserve seulement les sécurisés.

Usage : SPA, mobile, applications web avec utilisateur

PKCE (Proof Key for Code Exchange, prononcé « pixy ») résout un problème précis : sur un mobile ou dans un navigateur, le code d'autorisation transite par une redirection que d'autres applications peuvent intercepter. Sans PKCE, quiconque capte ce code peut l'échanger contre un jeton. Avec PKCE, l'échange n'aboutit que si le demandeur présente le code_verifier d'origine, un secret qui n'a jamais circulé sur le réseau. Les six étapes ci-dessous se déroulent en quelques centaines de millisecondes, l'utilisateur ne perçoit que l'écran de connexion.

  1. Génération du verifier

    Le client génère un code_verifier aléatoire (32 octets, base64url).

  2. Calcul du challenge

    Le client calcule code_challenge = BASE64URL(SHA256(code_verifier)) et déclare code_challenge_method=S256. La valeur plain, qui transmet le verifier tel quel, existe encore dans la RFC mais n'offre aucune protection.

  3. Requête d'autorisation

    Le client redirige vers l'Authorization Server avec le code_challenge.

  4. Authentification + consentement

    L'utilisateur s'authentifie et accepte les scopes.

  5. Retour avec le code

    L'Authorization Server redirige vers le client avec un authorization_code.

  6. Échange du code

    Le client envoie le code + code_verifier et reçoit les tokens.

// Étape 1-2 : Génération PKCE
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
// Étape 3 : Requête d'autorisation
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', 'my-app');
authUrl.searchParams.set('redirect_uri', 'https://myapp.com/callback');
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('state', generateRandomState()); // Anti-CSRF
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
// Rediriger l'utilisateur vers authUrl
// Étape 6 : Échange du code côté callback
const tokenResponse = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: 'https://myapp.com/callback',
client_id: 'my-app',
code_verifier: codeVerifier, // Prouve qu'on est l'initiateur
}),
});
const { access_token, refresh_token, id_token } = await tokenResponse.json();

Ces trois paramètres protègent contre trois attaques distinctes et ne se remplacent pas. Le state défend la session du navigateur contre un déclenchement forcé du flow par un tiers, le code_challenge défend le code d'autorisation contre l'interception, le nonce défend l'ID token OIDC contre le rejeu. Une erreur fréquente consiste à croire que PKCE dispense du state : ce n'est pas le cas, les deux couvrent des étapes différentes du parcours et les bonnes pratiques de sécurité OAuth demandent les deux.

ParamètreRôleQuand l'utiliser
stateAnti-CSRF, lie la requête à la sessionToujours
code_challengePKCE, protège contre l'interceptionToujours (OAuth 2.1)
nonceAnti-replay pour OIDCAvec OIDC

Le point à retenir de ce code n'est pas la génération mais la comparaison au retour. Un state généré puis jamais vérifié ne sert à rien, et c'est l'oubli le plus courant en revue de code. Notez également le stockage : sessionStorage est cloisonné par onglet, ce qui suffit ici car le flow se déroule dans un seul onglet ; avec localStorage, deux connexions lancées en parallèle s'écraseraient mutuellement.

// Avant la redirection
const state = crypto.randomBytes(16).toString('hex');
sessionStorage.setItem('oauth_state', state);
// Au retour du callback
const returnedState = new URLSearchParams(location.search).get('state');
const savedState = sessionStorage.getItem('oauth_state');
if (returnedState !== savedState) {
throw new Error('State mismatch - possible CSRF attack');
}

La colonne à lire en premier est Stockage, car c'est là que se jouent la plupart des compromissions. Un jeton placé dans localStorage est lisible par n'importe quel script injecté sur la page, donc une faille XSS suffit à l'exfiltrer. Garder l'access token en mémoire JavaScript et le refresh token dans un cookie HttpOnly met les deux hors de portée du code de page. La différence de durée de vie découle de cette exposition : l'access token circule à chaque appel d'API, donc il doit expirer vite.

TokenDurée de vieUsageStockage
Access TokenCourt (5-60 min)Appels APIMémoire (SPA), secure storage (mobile)
Refresh TokenLong (heures/jours)Obtenir un nouveau access tokenCookie HttpOnly, secure storage

Le commentaire de la dernière ligne signale le piège principal de cette requête. Si le serveur pratique la rotation, la réponse contient un nouveau refresh token et l'ancien devient invalide immédiatement. Une application qui ignore ce champ et continue d'utiliser le jeton initial verra ses renouvellements échouer, puis, sur les serveurs qui détectent la réutilisation, se fera révoquer toute la chaîne de jetons et déconnectera l'utilisateur.

const refreshResponse = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: currentRefreshToken,
client_id: 'my-app',
}),
});
const { access_token, refresh_token } = await refreshResponse.json();
// Attention : refresh_token peut être nouveau (rotation)

La rotation émet un nouveau refresh token à chaque utilisation. Si un token est volé et utilisé :

  1. L'attaquant obtient un nouveau token
  2. La victime utilise l'ancien token (invalide)
  3. Le serveur détecte l'anomalie et révoque toute la chaîne

La révocation, définie par la RFC 7009, est le seul moyen de couper un accès avant l'expiration naturelle du jeton. Deux limites à connaître. Elle n'est pas obligatoire dans OAuth 2.0, tous les serveurs d'autorisation ne l'exposent pas. Et sur les jetons JWT validés localement par le Resource Server, révoquer côté serveur d'autorisation ne suffit pas : le jeton reste cryptographiquement valide jusqu'à son expiration, sauf si le Resource Server interroge l'introspection à chaque appel.

Fenêtre de terminal
# Révoquer un token (côté client)
curl -X POST https://auth.example.com/revoke \
-d "token=eyJhbGciOiJSUzI1NiIs..." \
-d "client_id=my-app"

Pour les jetons opaques (non-JWT), le Resource Server doit les valider auprès de l'Authorization Server. Ce détour réseau à chaque appel est le prix de la révocation immédiate : contrairement à un JWT auto-porteur, un jeton opaque ne veut rien dire hors du serveur qui l'a émis, donc un jeton révoqué cesse d'être accepté à la seconde suivante. Le champ à tester dans la réponse est active ; les autres champs ne doivent jamais être exploités si active vaut false.

Fenêtre de terminal
curl -X POST https://auth.example.com/introspect \
-u api-resource-server:secret \
-d "token=abc123opaque"

Réponse :

{
"active": true,
"sub": "user-123",
"scope": "read:orders",
"exp": 1700000000
}

Aucune de ces six failles n'exploite une faiblesse cryptographique : toutes viennent d'une configuration ou d'une implémentation incomplète. C'est la bonne nouvelle, elles se corrigent sans changer de bibliothèque. Les deux premières lignes sont les plus graves parce qu'elles mènent directement à un vol de jeton ; les deux dernières sont les plus répandues parce qu'elles résultent d'un choix de confort, une durée de vie allongée ou un scope élargi pour « éviter les problèmes ».

VulnérabilitéCauseProtection
Authorization code interceptionPas de PKCEPKCE obligatoire
Open redirectRedirect URI avec wildcardExact match
Token leak via refererToken dans l'URLNe pas mettre de token en query string
CSRFPas de stateToujours envoyer et valider state
Token trop longDurée de vie excessiveAccess token < 1h, refresh token avec rotation
Scope trop largePermissions excessivesMoindre privilège

OAuth 2.1 ne crée aucun mécanisme nouveau : il consolide en un seul document les bonnes pratiques déjà publiées depuis 2012 dans des RFC séparées, et retire les éléments jugés dangereux. Autrement dit, une implémentation OAuth 2.0 rigoureuse est déjà presque conforme. Le tableau ci-dessous se lit donc comme une liste de choix qui étaient facultatifs et deviennent obligatoires, pas comme une migration technique.

ChangementOAuth 2.0OAuth 2.1
PKCEOptionnelObligatoire pour tous les clients
Implicit flowDisponibleSupprimé
ROPCDisponibleSupprimé
Redirect URIMatching flexible possibleExact match obligatoire
Refresh token rotationNon spécifiéRecommandé pour public clients

Ces cinq erreurs se rencontrent surtout sur les clients publics, c'est-à-dire les applications qui s'exécutent chez l'utilisateur et ne peuvent donc garder aucun secret : une SPA, une application mobile, un outil en ligne de commande. La deuxième ligne est la plus révélatrice d'une incompréhension du modèle : un client_secret embarqué dans du JavaScript livré au navigateur n'est pas un secret, il est lisible par tout visiteur. La réponse n'est pas de l'obfusquer mais de déclarer le client comme public et de s'appuyer sur PKCE.

ErreurConséquenceSolution
Pas de PKCE sur client publicVulnérable à l'interceptionPKCE obligatoire
client_secret côté client (SPA)Secret exposéClient public sans secret, utiliser PKCE
Refresh token stocké en localStorageXSS = volCookie HttpOnly ou mémoire + refresh silencieux
Pas de validation du stateCSRF possibleGénérer et valider un state aléatoire
Access token de 24h24h d'accès post-compromissionToken courts (15-60 min) + refresh

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn