Aller au contenu
English

Lire un jeton : JWT, signatures et clés

  • Leçon 6
  • 50 min
  • Module 2 : Comprendre la connexion et les jetons

Ce que vous saurez faire

Ouvrez un jeton et lisez ses trois parties. Découvrez ce que prouve la signature du fournisseur et ses limites. Voyez comment l’API trouve la bonne clé publique pour la vérifier.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 50 minutes

Ce que vous devez savoir

  • Le jeton d’accès et le jeton d’identité d’une connexion, présentés dans Flux d’application.
  • SHA-256 comme empreinte d’un texte, présenté dans l’exemple PKCE de la même leçon.

Ce qu’il vous faut sur votre ordinateur

  • Le projet après Flux d’application, avec scripts/login.py.
  • Le fournisseur en cours d’exécution dans son propre terminal (python -m idp), et une connexion enregistrée : .tokens/usr-sam.json. Si vous l’avez supprimée, exécutez de nouveau python -m scripts.login --user usr-sam.

Cette leçon ajoute un fichier, scripts/token.py. Les jetons de cette leçon sont de vrais jetons du fournisseur d’exercice. Ils sont expirés, et une clé locale fictive les a signés. Ils ne donnent donc accès à rien. Les commandes ont été testées sur macOS (Apple silicon). Les commandes Windows sont écrites, mais n’ont pas été testées.

Regardez un billet de banque. Tout le monde peut le lire : sa valeur, son numéro de série, la banque qui l’a émis. Rien n’y est secret.

Tout le monde peut aussi le vérifier. La banque publie les caractéristiques d’un vrai billet : le filigrane, le fil de sécurité, l’encre spéciale. Un commerçant vérifie ces caractéristiques sans consulter la banque. Mais seule la banque peut fabriquer un billet qui réussit cette vérification.

Un vrai billet prouve deux choses : la banque l’a fabriqué, et personne ne l’a modifié. Il ne prouve pas que la personne qui le présente en est propriétaire. Un billet volé s’utilise comme n’importe quel autre.

Le problème : une chaîne que tout le monde peut lire et modifier

Section intitulée « Le problème : une chaîne que tout le monde peut lire et modifier »

Le jeton d’accès de Sam compte 732 caractères. Il semble incompréhensible. On peut donc facilement croire qu’il est secret ou que personne ne peut le modifier. Ni l’un ni l’autre n’est vrai.

Voici un vrai jeton d’accès provenant d’une connexion de Camille, du même type que celui de Sam. Il commence ainsi :

eyJhbGciOiJSUzI1NiIsImtpZCI6IjdIeGNCdGs0ZGpkcnNQekIiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0Ojg0MDAiLCJzdWIiOiJ1c3ItY2FtaWxsZSIsImF1ZCI6InRpY2tldC1hcGkiLCJleHAiOjE3OTE2MTE3NzAsIm5i…

L’auteur y a changé un mot : usr-camille est devenu usr-grace, la propriétaire de Larkfield. Cette modification est un cas construit pour le cours. Un programme qui décode seulement le jeton lit maintenant :

sub "usr-grace"

Si l’API faisait confiance à ce qu’elle lit, Camille serait maintenant Grace. L’API a donc besoin d’une chose que tout le monde peut lire, mais que personne ne peut falsifier : une signature.

Les jetons du fournisseur sont des JSON Web Tokens (JWT, souvent prononcé « jot »). Un JWT comporte trois parties, séparées par deux points :

  1. L’en-tête : comment le jeton est signé. alg est l’algorithme, kid l’identifiant de clé, typ le type de jeton.
  2. Les revendications : ce que dit le jeton. Chaque revendication (claim) est un énoncé, composé d’un nom et d’une valeur, comme "sub": "usr-camille".
  3. La signature : la preuve du fournisseur qu’il a créé les deux premières parties, exactement telles qu’elles sont.

L’en-tête et les revendications sont du JSON, écrit en base64url. Base64url n’est pas du chiffrement. Il écrit simplement les octets sous forme de texte utilisable dans une adresse et dans un en-tête HTTP. Il prend les octets par groupes de 3, soit 24 bits. Il les écrit sous forme de 4 caractères de 6 bits chacun. Une valeur de 6 bits, de 0 à 63, choisit un caractère parmi A–Z, a–z, 0–9, - et _.

Voici un petit exemple où chaque bit est visible. L’en-tête de chaque jeton commence par les trois caractères {"a :

{ " a
Octets 123 34 97
Bits 01111011 00100010 01100001
Les mêmes 24 bits en groupes de 6 011110 = 30 110010 = 50 001001 = 9, puis 100001 = 33
Caractères e (30) y (50) J (9), puis h (33)

Ainsi, {"a devient eyJh, et l’en-tête complet {"alg":"RS256"} devient eyJhbGciOiJSUzI1NiJ9. C’est pourquoi presque tous les JWT commencent par eyJ : c’est {" en base64url. Base64url supprime les caractères de remplissage = à la fin. Il utilise aussi - et _ là où base64 utilise + et /. Ces deux derniers caractères ont un sens dans une adresse.

Copiez le script de lecture des jetons depuis l’instantané, puis décodez le jeton d’accès enregistré de Sam :

Fenêtre de terminal
cp ../nextia-learning-labs/C23/snapshots/end-of-m02/scripts/token.py scripts/
python -m scripts.token decode --user usr-sam

Votre sortie affiche usr-sam et votre propre clé. Voici la même commande pour le jeton de Camille. L’auteur l’a exécutée en remplaçant --user usr-sam par le jeton complet :

A JWT is three parts, separated by dots: 74, 319 and 342 characters.
1. header (base64url JSON): {"alg": "RS256", "kid": "7HxcBtk4djdrsPzB", "typ": "at+jwt"}
2. claims (base64url JSON):
iss "http://localhost:8400"
sub "usr-camille"
aud "ticket-api"
exp 1791611770 (2026-10-10 05:56:10 UTC)
nbf 1791611170 (2026-10-10 05:46:10 UTC)
iat 1791611170 (2026-10-10 05:46:10 UTC)
jti "14dafe2628fe4233891734b84ad77238"
client_id "ticket-cli"
scope "tickets:read tickets:write members:manage"
3. signature: dX880F0-mK2-D7Yu... (256 bytes)
Decoding is not checking: anyone can read these parts. Only the signature, checked with
the provider's public key '7HxcBtk4djdrsPzB', shows that the provider made them.

Les noms des revendications sont courts par choix : chaque requête les transporte. Chacune a un sens précis :

Revendication Nom Valeur de Camille Ce qu’elle dit
iss émetteur http://localhost:8400 Quel fournisseur a créé le jeton
sub sujet usr-camille Qui le jeton concerne : un identifiant d’utilisateur stable qui ne change jamais
aud audience ticket-api À quelle API le jeton est destiné
exp expiration 5 h 56 min 10 s UTC Non valide après ce moment
nbf pas avant 5 h 46 min 10 s UTC Non valide avant ce moment
iat émis à 5 h 46 min 10 s UTC Quand le fournisseur l’a créé
jti identifiant de jeton 14dafe26… Un identifiant unique pour ce jeton précis
client_id ticket-cli Quelle application l’a demandé
scope trois portées Ce que l’application peut faire pour Camille

Les heures sont en secondes Unix : les secondes écoulées depuis le 1 janvier 1970, UTC. exp moins iat donne 1 791 611 770 − 1 791 611 170 = 600 secondes : 10 minutes, comme l’indiquait expires_in du fournisseur. Le jeton ne contient ni organisation ni rôle, selon la décision prise dans OAuth et OIDC.

La signature : facile à vérifier, impossible à falsifier sans la clé

Section intitulée « La signature : facile à vérifier, impossible à falsifier sans la clé »

Le fournisseur signe avec RS256 : une signature RSA de l’empreinte SHA-256 des deux premières parties, header.claims. RSA utilise une paire de clés. La clé privée crée les signatures et ne quitte jamais le dossier .idp/keys/ du fournisseur. La clé publique vérifie les signatures, et tout le monde peut l’avoir. C’est comme le billet de banque : tout le monde peut vérifier, seule la banque peut fabriquer.

Un exemple simplifié montre les calculs avec des nombres assez petits pour les suivre. Les vraies clés utilisent des nombres de plus de 600 chiffres. Ceux-ci sont beaucoup trop petits pour être sûrs. La paire de clés simplifiée utilise n = 3 233, le nombre public e = 17 et le nombre privé d = 2 753 :

  1. Le fournisseur a une empreinte à signer. Disons que c’est le nombre 65.
  2. Il signe avec le nombre privé : 652753 mod 3 233 = 588. La signature est 588.
  3. L’API vérifie avec les nombres publics : 58817 mod 3 233 = 65. Le résultat égale l’empreinte, donc la signature correspond.
  4. Quelqu’un modifie les revendications, et leur empreinte est maintenant 66. L’ancienne signature donne toujours 65, et 65 ≠ 66 : refus.
  5. Pour signer 66, le faussaire a besoin du nombre privé. Sans lui, un essai comme 589 donne 2 838 à la vérification, pas 66.

(« mod 3 233 » signifie « le reste après la division par 3 233 ».) La vraie signature fait 256 octets : 342 caractères base64url. Changer une lettre dans les revendications change complètement l’empreinte, comme l’a montré l’exemple PKCE. L’ancienne signature ne correspond plus.

À gauche, le fournisseur d'identité signe : l'en-tête, les revendications et la clé privée avec l'identifiant kid 7HxcBtk4djdrsPzB entrent dans « signer avec RS256 ». Cette clé se trouve seulement dans .idp/keys/. Le résultat est le jeton, header.claims.signature, de 732 caractères, que tout le monde peut lire. Une flèche pointillée montre que le fournisseur publie la moitié publique de la même paire de clés. À droite, ticket-api vérifie : la clé publique provenant de /jwks.json, avec le kid de l'en-tête, et le jeton entrent dans « vérifier avec RS256 ». Deux résultats : correspondance, qui signifie que le fournisseur l'a créé et que rien n'a changé depuis; ou aucune correspondance, qui signifie un refus avec bad_signature.
Le fournisseur signe avec sa clé privée; l'API vérifie avec la moitié publique, trouvée grâce à l'identifiant de clé. Seul le fournisseur peut signer; tout le monde peut vérifier.
Description textuelle du diagramme

Suivez le jeton de gauche à droite. Le fournisseur signe header.claims avec sa clé privée. Le jeton passe par le navigateur ou un script, et tout le monde sur son trajet peut le lire. L’API lit le kid dans l’en-tête. Elle prend la clé publique avec cet identifiant dans la liste de clés du fournisseur, puis vérifie la signature. Une correspondance prouve deux choses : le fournisseur a créé le jeton, et aucun caractère de l’en-tête ou des revendications n’a changé depuis.

Le fournisseur publie ses clés publiques sous forme de JWKS, un JSON Web Key Set, ou ensemble de clés Web JSON. Il le publie à l’adresse jwks_uri de son document de découverte. Demandez-le :

Fenêtre de terminal
curl -s http://localhost:8400/jwks.json | python -m json.tool
{
"keys": [
{
"kty": "RSA",
"key_ops": ["verify"],
"n": "1Gad4mdVxOgzjDlPIplFn14xf3FjTSknTY8gXNTAC3UqJ7YwSKwuqPsfwYEmv1VI5-Dmx15c...",
"e": "AQAB",
"kid": "7HxcBtk4djdrsPzB",
"use": "sig",
"alg": "RS256"
}
]
}

Chaque clé est un objet JSON. kty est le type de clé, RSA. n et e sont les deux nombres publics, comme 3 233 et 17 dans l’exemple simplifié. Mais n compte 2 048 bits. kid est l’identifiant de clé que les jetons indiquent dans leur en-tête. use et key_ops indiquent que cette clé vérifie les signatures. L’auteur a raccourci n ici; le vôtre compte environ 340 caractères. Cette liste vient de l’exécution qui a créé le jeton de Camille, donc son kid est 7HxcBtk4djdrsPzB. Le vôtre est l’identifiant de votre propre clé. Cette liste ne contient aucun nombre privé.

Pourquoi une liste et un identifiant de clé, plutôt qu’une seule clé? Parce que les clés changent. Un fournisseur remplace sa clé de signature de temps en temps, ou immédiatement si une clé a peut-être été divulguée. C’est la rotation des clés. Pendant une rotation, la liste contient l’ancienne clé et la nouvelle. Chaque jeton indique sa clé avec kid. Essayez :

  1. Ajoutez une nouvelle clé. Sans --activate, elle est publiée, mais ne signe pas encore :

    Fenêtre de terminal
    python -m idp rotate-key
    New key pHlo1LoZvVE4kFQ9: published. Activate it later: python -m idp activate-key pHlo1LoZvVE4kFQ9
  2. Listez les clés :

    Fenêtre de terminal
    python -m idp keys
    My9O_SxNpIg4Ff10 published signs new tokens
    pHlo1LoZvVE4kFQ9 published

    Ouvrez de nouveau http://localhost:8400/jwks.json : il contient maintenant deux clés.

  3. Faites signer la nouvelle clé. Utilisez votre propre identifiant de clé de l’étape 1 :

    Fenêtre de terminal
    python -m idp activate-key pHlo1LoZvVE4kFQ9
  4. Connectez-vous de nouveau, puis décodez l’en-tête du nouveau jeton :

    Fenêtre de terminal
    python -m scripts.login --user usr-sam
    python -m scripts.token decode --user usr-sam

    L’en-tête indique maintenant "kid": "pHlo1LoZvVE4kFQ9". La clé des jetons avec l’ancien kid reste publiée. Leur vérification réussit donc encore jusqu’à leur expiration. Plus tard, python -m idp retire-key <old kid> cesse de publier l’ancienne clé.

Pourquoi publier d’abord et signer ensuite? Une API garde la liste de clés en mémoire pendant un certain temps. Si une clé signe avant que l’API l’ait, l’API ne peut pas encore vérifier les jetons produits. Ce que signifie un jeton mesure la durée de cette situation.

L’explorateur contient le vrai jeton d’accès de Camille. Changez une chose. Voyez quelle partie du jeton change et si la signature correspond encore. La colonne de droite montre toutes les vérifications de l’API. La prochaine leçon les explique une par une.

Explorer

Un jeton d'accès : ses trois parties et les vérifications de l'API

Le jeton d’accès exactement tel que le fournisseur l’a émis. Accepté : toutes les vérifications ont réussi. L’horloge de l’API dans cet explorateur : 2026-10-10 05:48:10 UTC, deux minutes après la connexion.

Le jeton

en-tête . revendications . signature · changé = diffère du jeton tel qu’émis

eyJhbGciOiJSUzI1NiIsImtpZCI6IjdIeGNCdGs0ZGpkcnNQekIiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0Ojg0MDAiLCJzdWIiOiJ1c3ItY2FtaWxsZSIsImF1ZCI6InRpY2tldC1hcGkiLCJleHAiOjE3OTE2MTE3NzAsIm5iZiI6MTc5MTYxMTE3MCwiaWF0IjoxNzkxNjExMTcwLCJqdGkiOiIxNGRhZmUyNjI4ZmU0MjMzODkxNzM0Yjg0YWQ3NzIzOCIsImNsaWVudF9pZCI6InRpY2tldC1jbGkiLCJzY29wZSI6InRpY2tldHM6cmVhZCB0aWNrZXRzOndyaXRlIG1lbWJlcnM6bWFuYWdlIn0.dX880F0-mK2-D7Yu94CnoeV7_dujRDIBmC25k1F8eZrPlXlHlLmWNsSNfYvx4dt9Rk2nfpoec4eZhuWYJGf7k9NgoyApre6hxt0jpysfR5RoWofmMlNWBaZWQaMYjiGPXhCqzhdoJc2qnSEMyzr6jnDAWAUiJn89ejMObwNozADY6v9sANTiyGzxa5Va9ggl9tDw0syBpZU7bzRQsxm-PBljrpLX-yCP8wZswpINMkrO6Z33Pd0xCszmQLwEebF_ExG5n_8U-2LcrRyd2DaeKXplDgNtO32tbhGLLTSz7xQlJStwZHyQ_Y4dEgQlrsBYWuglka4EXcpKab6-VxIbgw

1. En-tête
alg"RS256"
kid"7HxcBtk4djdrsPzB"
typ"at+jwt"
2. Revendications
iss"http://localhost:8400"
sub"usr-camille"
aud"ticket-api"
exp17916117702026-10-10 05:56:10 UTC
nbf17916111702026-10-10 05:46:10 UTC
iat17916111702026-10-10 05:46:10 UTC
jti"14dafe2628fe4233891734b84ad77238"
client_id"ticket-cli"
scope"tickets:read tickets:write members:manage"

3. Signature : La vraie signature du fournisseur.

Les vérifications de l’API, dans l’ordre

  1. 1. Un JWT : trois parties base64urlréussie : l’en-tête et les revendications se décodent
  2. 2. Algorithme dans la liste d’autorisation (RS256)réussie : alg vaut "RS256"
  3. 3. Un jeton d’accès (typ at+jwt)réussie : typ vaut "at+jwt"
  4. 4. Identifiant de clé publié par le fournisseurréussie : kid "7HxcBtk4djdrsPzB"
  5. 5. La signature correspond à cette clé publiqueréussie : correspond à la clé publique de ce kid
  6. 6. Revendications obligatoires présentesréussie : exp, iat, iss, aud, sub
  7. 7. Entre nbf et exp (tolérance de 30 s)réussie : exp est dans 480 s
  8. 8. L’émetteur est notre fournisseurréussie : iss vaut "http://localhost:8400"
  9. 9. L’audience est ticket-apiréussie : aud vaut "ticket-api"

GET /v1/me → 200 OK

L’API accepte le jeton : l’appelant est le sub du jeton.

Un vrai jeton d'accès du fournisseur d'exercice (une connexion de usr-camille; expiré, signé avec la clé simulée locale de l'auteur). Les réponses de l'API sont les vraies réponses aux mêmes jetons fictifs (cas de scripts.token).
Description textuelle de l’explorateur

L’explorateur montre un vrai jeton d’accès du fournisseur d’exercice : une connexion de usr-camille, de 737 caractères. Il est expiré et a été signé avec une clé simulée locale. Le jeton apparaît en trois parties colorées : en-tête, revendications et signature. En dessous figurent l’en-tête et les revendications décodés, ainsi qu’une note sur la signature. À droite, les neuf vérifications de l’API sont listées dans l’ordre, puis vient la réponse de l’API à GET /v1/me. Dans l’explorateur, l’horloge de l’API indique deux minutes après la connexion. Le jeton inchangé y est donc valide.

Tel qu’il a été émis, le jeton réussit chaque vérification et l’API répond 200. Avec « sub : usr-grace, edited » (sub : usr-grace, modifié), seule la partie du milieu, les revendications, change. L’en-tête et la signature restent identiques. La vérification de signature, la cinquième, échoue avec bad_signature, car l’ancienne signature a été créée pour les anciennes revendications.

Les autres boutons changent chacun une chose, et une vérification refuse chaque changement. La vérification de l’audience refuse aud billing-api (wrong_audience). La vérification du temps refuse exp il y a 5 minutes (expired) et nbf dans 5 minutes (not_yet_valid). Elle accepte exp il y a 10 secondes, dans la tolérance de 30 secondes. La vérification de l’émetteur refuse iss localhost:9999 (wrong_issuer), et celle des revendications refuse un sub manquant (missing_claim). La vérification du type de jeton refuse le jeton d’identité de Camille (wrong_token_type). Si cette vérification est désactivée, celle de l’audience le refuse (wrong_audience). La liste d’autorisation refuse alg none et alg HS256 (algorithm_not_allowed). La vérification de clé refuse un kid que le fournisseur n’a jamais publié (unknown_key). La vérification de signature refuse une autre clé avec le kid du fournisseur (bad_signature). La vérification du format refuse la chaîne abc.def (malformed). Pour le cours, l’auteur a signé de nouveau chaque jeton modifié, sauf celui dont le sub a été modifié. La clé du fournisseur ou une autre clé l’a signé, pour qu’une seule vérification à la fois le refuse.

Ce que cela nous apprend. Une modification change la partie qui la contient. La signature ne peut pas suivre, car seule la clé privée peut en créer une nouvelle. La vérification de signature détecte donc chaque modification, quel que soit son contenu.

Une signature valide prouve Une signature valide ne prouve pas
Le fournisseur qui possède cette clé a créé le jeton. Que le jeton est secret. Tout le monde peut le décoder. Un jeton ne doit donc jamais contenir un mot de passe ou d’autres données secrètes.
Aucun caractère de l’en-tête ou des revendications n’a changé. Que le jeton est destiné à cette API. Un jeton pour billing-api a aussi une signature valide.
Que le jeton est encore valide. Un jeton expiré garde sa signature valide pour toujours.
Que l’expéditeur est la personne indiquée dans sub. Comme un billet de banque, un jeton fonctionne pour quiconque le détient : c’est un jeton au porteur.

La colonne de droite explique pourquoi une signature valide est le début, pas la fin. Ce que signifie un jeton ajoute les vérifications de l’audience, du temps, de l’émetteur et du type de jeton. Le module 3 réduit le risque lié au porteur : les jetons restent sur le serveur, et le navigateur reçoit seulement un témoin (cookie).

Quand utiliser des jetons autonomes, et ce qu’ils coûtent

Section intitulée « Quand utiliser des jetons autonomes, et ce qu’ils coûtent »

Un JWT est autonome : l’API le vérifie avec une clé publique, sans appeler le fournisseur à chaque requête. Cela rend la vérification rapide et évite de faire intervenir le fournisseur dans chaque requête. Les coûts découlent du même fait :

  • Aucune fin anticipée. Le fournisseur ne peut pas révoquer un JWT avant exp. Le jeton d’une personne dont le compte est désactivé fonctionne jusqu’à son expiration. Une courte durée de vie limite ce risque : 10 minutes ici. Le module 5 le mesure.
  • Taille. 732 caractères accompagnent chaque requête.
  • Tout le monde peut le lire. Gardez les revendications courtes et sans danger : des identifiants, pas des renseignements personnels. Le jeton d’accès ici ne contient ni nom ni courriel.

Certains fournisseurs émettent plutôt des jetons opaques : des chaînes aléatoires que seul le fournisseur peut lire. L’API interroge alors le fournisseur sur chaque jeton, par un appel nommé introspection. Cela permet une fin anticipée, au prix d’un appel supplémentaire par requête. Ce cours utilise des JWT, comme la plupart des fournisseurs le font pour les API.

Décodez le jeton d’identité de la même connexion et trouvez trois différences avec le jeton d’accès. Prédisez-les d’abord.

Fenêtre de terminal
python -m scripts.token decode --user usr-sam --id-token
Afficher la réponse

L’exécution de l’auteur, après une nouvelle connexion :

A JWT is three parts, separated by dots: 70, 298 and 342 characters.
1. header (base64url JSON): {"alg": "RS256", "kid": "My9O_SxNpIg4Ff10", "typ": "JWT"}
2. claims (base64url JSON):
iss "http://localhost:8400"
sub "usr-sam"
aud "ticket-cli"
exp 1791613209 (2026-10-10 06:20:09 UTC)
iat 1791612609 (2026-10-10 06:10:09 UTC)
auth_time 1791612609 (2026-10-10 06:10:09 UTC)
name "Sam"
email "sam@larkfield.example"
email_verified true
nonce "7jPwIjXDl9Xe3KTUl3bh3g"
3. signature: R8x4xTemR8kFZVaD... (256 bytes)
Decoding is not checking: anyone can read these parts. Only the signature, checked with
the provider's public key 'My9O_SxNpIg4Ff10', shows that the provider made them.
  1. typ vaut JWT, et non at+jwt : l’en-tête indique que ce n’est pas un jeton d’accès.
  2. aud vaut ticket-cli, l’application, et non ticket-api.
  3. Il contient les champs name (nom), email (courriel), auth_time (moment où Sam s’est connecté) et nonce. Il ne contient ni scope (portée) ni client_id (identifiant du client).

Le même fournisseur et la même clé ont signé les deux jetons. Les deux signatures sont donc valides. La signature seule ne permet pas à l’API de savoir quel jeton elle a reçu. L’en-tête et les revendications le permettent.

Erreur courante

Faire confiance à un jeton seulement décodé

Symptôme (construit) : un script utilitaire détermine qui fait l’appel avec jwt.decode(token, options={"verify_signature": False}). La copie modifiée du jeton de Camille passe. Le script considère alors que l’appelant est usr-grace, le propriétaire de Larkfield.

Cause : le décodage lit seulement le base64url. Il ne vérifie rien. scripts/login.py et scripts/token.py décodent sans vérifier, volontairement, pour montrer un jeton. Les deux l’indiquent dans leur sortie : « decoded, not checked » (décodé, non vérifié).

Diagnostic : cherchez dans le code les endroits qui lisent un jeton sans le vérifier :

Fenêtre de terminal
grep -rn "verify_signature\|get_unverified" --include='*.py' ticket_api scripts idp

À cette étape, huit lignes correspondent dans votre projet. Sept sont dans scripts/, pour montrer les jetons. Une est dans idp/app.py : elle lit seulement l’en-tête pour choisir la clé. Après la prochaine leçon, une neuvième ligne se trouve dans ticket_api/auth.py. Elle aussi lit seulement l’en-tête, pour choisir la vérification. Toute autre correspondance qui décide de l’accès constitue le bogue.

Correction :

  1. Décidez de l’accès seulement avec un jeton vérifié par le validateur de jetons de l’API, construit dans la prochaine leçon.
  2. Lisez un en-tête non vérifié seulement pour choisir comment vérifier, jamais s’il faut faire confiance.
  3. Ajoutez un test qui envoie un jeton modifié et attend une réponse 401. scripts.token cases en contient un : tampered (modifié).

Vérifiez votre compréhension

Pourquoi presque tous les JWT commencent-ils par les trois mêmes caractères, eyJ? Qu'est-ce que cela vous apprend sur la confidentialité du contenu d'un jeton?

Afficher la réponse

eyJ représente en base64url les deux caractères {" qui commencent chaque en-tête JSON. Base64url transforme seulement les octets en caractères sûrs, à raison de 4 caractères pour 3 octets. Ce n’est pas du chiffrement. Toute personne qui possède un jeton peut donc lire son en-tête et ses revendications. Ne mettez jamais de secret dans un jeton. Traitez le jeton lui-même comme un secret, car toute personne qui le possède peut l’utiliser.

Vérifiez votre compréhension

L'API possède la clé publique du fournisseur. L'API, ou un attaquant qui lit /jwks.json, peut-elle créer un jeton qu'elle accepte? Pourquoi?

Afficher la réponse

Non. La clé publique peut seulement vérifier les signatures. Pour créer une signature, il faut la clé privée, qui reste dans le dossier .idp/keys/ du fournisseur. Dans l’exemple simplifié, les nombres publics 3 233 et 17 vérifient la signature 588. Sans le nombre privé 2 753, personne ne peut calculer la signature d’une nouvelle empreinte. C’est pourquoi le fournisseur peut publier la liste des clés pour tout le monde.

Vérifiez votre compréhension

Un jeton a une signature valide de votre fournisseur. Nommez trois raisons pour lesquelles l'API pourrait quand même devoir le refuser.

Afficher la réponse

Il peut être destiné à une autre API (aud n’est pas ticket-api). Il peut être expiré (exp est dans le passé) ou pas encore valide (nbf est dans le futur). Il peut s’agir d’un jeton d’identité, et non d’un jeton d’accès (typ vaut JWT, aud désigne l’application). Une signature valide ne prouve pas non plus que l’expéditeur est la personne désignée par sub. Toute personne qui possède le jeton peut l’envoyer.

Récapitulatif

  • Un JWT comporte trois parties en base64url : l’en-tête (alg, kid, typ), les revendications et la signature. Base64url n’est pas du chiffrement : {" devient eyJ, et tout le monde peut lire chaque revendication.
  • Les revendications du jeton d’accès : iss, sub, aud, exp, nbf, iat, jti, client_id, scope, avec les heures en secondes Unix. Aucune organisation, aucun rôle, aucun nom.
  • RS256 : le fournisseur signe avec sa clé privée; tout le monde peut vérifier avec la clé publique. Une revendication modifiée ne correspond plus à sa signature.
  • Le fournisseur publie ses clés publiques sous forme de JWKS à /jwks.json; le champ kid de l’en-tête désigne la clé. Pendant une rotation des clés, la liste contient deux clés.
  • Une signature valide prouve qui a créé le jeton et que rien n’a changé. Elle ne prouve ni l’audience, ni l’heure, ni le type de jeton, ni l’identité de l’expéditeur.
  • Ensuite : l’API vérifie tout cela, dans l’ordre, et indique quelle vérification a refusé un jeton.

Leçon suivante : Ce que signifie un jeton