Skip to Content
MoodlePartie 10 — LTI (Learning Tools Interoperability)LTI 1.3 sous le capot : OIDC, JWT et les services Advantage

Chapitre 10.2 — LTI 1.3 sous le capot : OIDC, JWT et les services Advantage

Dans le chapitre précédent, nous avons vu pourquoi LTI existe : brancher un outil externe dans Moodle comme s’il faisait partie du cours, avec l’identité de l’utilisateur, son rôle et le contexte pédagogique transmis automatiquement. Ce chapitre-ci est le chapitre technique de référence de la partie 10 : nous allons démonter LTI 1.3 pièce par pièce, jusqu’au dernier claim JWT et au dernier header HTTP.

Si vous venez du monde web moderne — et c’est votre cas, avec Next.js, React et TypeScript — vous avez une excellente nouvelle : LTI 1.3 n’invente presque rien. C’est un assemblage de briques que vous connaissez déjà ou que vous pouvez apprendre en une soirée :

  • OpenID Connect (OIDC) dans une variante dite third-party initiated login, pour les lancements (launches) ;
  • JWT signés en RS256 comme format de tous les messages ;
  • OAuth 2.0 client_credentials avec assertion JWT (RFC 7523) pour les appels serveur-à-serveur ;
  • des API REST JSON tout à fait classiques pour les notes (AGS) et les inscrits (NRPS).

La difficulté de LTI 1.3 n’est pas conceptuelle : elle tient à la précision requise. Les URIs de claims sont des identifiants normatifs longs comme le bras (https://purl.imsglobal.org/spec/lti/claim/message_type), les validations de sécurité sont nombreuses et toutes obligatoires, et le moindre écart (un aud mal vérifié, un nonce accepté deux fois) transforme votre intégration en passoire. Ce chapitre vous donne tout : les flux complets, les JSON réels et intégraux, les requêtes HTTP brutes, et la checklist de validation normative.

Tout au long du chapitre, nous utiliserons deux acteurs fixes :

ActeurRôle LTIURLDétails
MoodlePlatform (plateforme)https://moodle.example.comissuer = https://moodle.example.com, client_id émis = a1b2c3d4e5f6g7h, deployment_id = 1
QuizLabTool (outil) — une app Next.jshttps://tool.example.cominitiate login = /api/lti/login, launch = /api/lti/launch, JWKS = /api/lti/jwks

Et les endpoints Moodle qui reviendront sans cesse :

EndpointURL Moodle
Authorization (OIDC auth request)https://moodle.example.com/mod/lti/auth.php
Token (OAuth2 access token)https://moodle.example.com/mod/lti/token.php
JWKS de la plateforme (clés publiques)https://moodle.example.com/mod/lti/certs.php

Mini-sommaire

  1. Vue d’ensemble : les deux jambes de LTI 1.3
  2. Le login OIDC third-party initiated, étape par étape
  3. Anatomie complète de l’id_token
  4. Validation côté outil : la checklist normative
  5. Les clés : qui signe quoi, qui vérifie avec quoi
  6. Les types de messages : ResourceLink, Deep Linking, Submission Review
  7. Les services LTI Advantage : token, AGS, NRPS
  8. Sécurité : pourquoi ça tient, et comment on le casse
  9. Ce qu’il faut retenir

1. Vue d’ensemble : les deux jambes de LTI 1.3

LTI 1.3 (et son extension « LTI Advantage ») repose sur deux mécanismes complémentaires, qu’il faut absolument distinguer dès le départ parce qu’ils n’empruntent pas le même chemin réseau et n’utilisent pas les mêmes credentials :

┌──────────────────────────────────────────────┐ │ LTI 1.3 / Advantage │ └──────────────────────────────────────────────┘ │ │ ┌─────────────┴─────────────┐ ┌────────────┴──────────────┐ │ JAMBE 1 : MESSAGES │ │ JAMBE 2 : SERVICES │ │ (launches) │ │ (API REST) │ ├───────────────────────────┤ ├───────────────────────────┤ │ • Passent par le │ │ • Serveur-à-serveur, │ │ NAVIGATEUR de l'user │ │ sans navigateur │ │ • OIDC third-party │ │ • OAuth2 client_ │ │ initiated login │ │ credentials + assertion │ │ • Payload = id_token JWT │ │ JWT (RFC 7523) │ │ signé par la PLATEFORME │ │ • Auth = Bearer token │ │ • Transport = form POST │ │ • AGS (notes), NRPS │ │ auto-soumis │ │ (inscrits), etc. │ └───────────────────────────┘ └───────────────────────────┘

Jambe 1 : les messages (launches)

Quand un étudiant clique sur l’activité « QuizLab » dans son cours Moodle, un message LTI est envoyé à l’outil. Ce message est un id_token JWT signé par Moodle, poussé à travers le navigateur de l’utilisateur via un formulaire HTML auto-soumis (response_mode=form_post). Le navigateur sert de facteur : il transporte le JWT de la plateforme vers l’outil, sans jamais que Moodle et QuizLab ne se parlent directement à cette étape.

Pourquoi passer par le navigateur ? Parce que le launch doit embarquer la session de l’utilisateur : c’est le navigateur qui possède le cookie de session Moodle (qui prouve qui est l’utilisateur côté plateforme), et c’est ce même navigateur qui devra ensuite posséder une session côté outil pour afficher le quiz. Le flux OIDC fait le pont entre les deux.

Jambe 2 : les services (LTI Advantage)

Une fois le launch effectué, QuizLab voudra renvoyer une note dans le carnet de notes Moodle (service AGS, Assignment and Grade Services) ou lister les inscrits du cours (service NRPS, Names and Role Provisioning Services). Ces appels-là ne passent pas par le navigateur : ce sont des requêtes REST serveur-à-serveur, authentifiées par un access token OAuth 2.0 que l’outil obtient via le grant client_credentials — mais avec une subtilité élégante : au lieu d’un client_secret partagé, l’outil s’authentifie avec une assertion JWT signée par sa propre clé privée RSA (RFC 7523). Aucun secret partagé ne transite jamais sur le réseau ni n’est stocké des deux côtés.

💡 Pour un dev React : mentalement, mappez la jambe 1 sur « NextAuth avec un provider OIDC exotique » et la jambe 2 sur « un appel fetch depuis un Route Handler Next.js avec un Bearer token en cache ». Le launch atterrit dans une route app/api/lti/launch/route.ts (POST !), et les appels AGS/NRPS partent de votre backend, jamais du composant client. Rien dans LTI ne s’exécute dans le navigateur côté outil, à part la réception du formulaire auto-soumis.

Pourquoi « OIDC third-party initiated login » ?

Dans un login OIDC classique (« Se connecter avec Google »), c’est l’application cliente (Relying Party) qui initie le flux : l’utilisateur est sur votre site, clique « Login with Google », et vous le redirigez vers Google. En LTI, c’est l’inverse : l’utilisateur est déjà sur la plateforme (Moodle), déjà authentifié, et c’est la plateforme qui décide « maintenant, on lance l’outil ». La plateforme est donc un tiers qui initie le login à la place de l’outil — d’où le nom de third-party initiated login, un mécanisme prévu par la section 4 de la spec OIDC Core et sur lequel LTI 1.3 s’appuie officiellement (« OpenID Connect Launch Flow »).

Concrètement, la plateforme ne peut pas fabriquer directement l’authorization request OIDC (elle ne connaît pas le state ni le nonce que l’outil voudra utiliser, et surtout l’outil doit pouvoir poser son propre cookie anti-CSRF). Elle envoie donc d’abord une login initiation request à l’outil, qui construit lui-même l’authorization request et renvoie l’utilisateur vers la plateforme. Trois allers-retours navigateur, que nous détaillons maintenant.


2. Le login OIDC third-party initiated, étape par étape

Voici le flux complet, dans sa version Moodle. Gravez ce diagramme dans votre mémoire : c’est LE cœur de LTI 1.3.

Reprenons chaque étape en détail, avec les requêtes HTTP brutes.

Étape (a) — La login initiation request : Moodle frappe à la porte de l’outil

Moodle rend au navigateur une page contenant un formulaire auto-soumis en JavaScript (il peut aussi utiliser un GET avec query string — la spec autorise les deux, l’outil doit accepter les deux). Voici ce que reçoit QuizLab :

POST /api/lti/login HTTP/1.1 Host: tool.example.com Content-Type: application/x-www-form-urlencoded iss=https%3A%2F%2Fmoodle.example.com &target_link_uri=https%3A%2F%2Ftool.example.com%2Fapi%2Flti%2Flaunch &login_hint=42 &lti_message_hint=%7B%22cmid%22%3A17%2C%22launchid%22%3A%22ltilaunch4_9f2c1a%22%7D &client_id=a1b2c3d4e5f6g7h &lti_deployment_id=1

Décortiquons les paramètres :

ParamètreObligatoireRôle
issouiL’issuer de la plateforme. Pour Moodle, c’est l’URL racine du site : https://moodle.example.com. C’est la clé de recherche principale : l’outil s’en sert pour retrouver la configuration de plateforme enregistrée (authorization endpoint, token endpoint, JWKS URL, client_id attendu). Si l’iss est inconnu → rejet immédiat.
login_hintouiValeur opaque identifiant l’utilisateur côté plateforme. Moodle y met l’id interne de l’utilisateur (ici 42). L’outil ne doit pas l’interpréter : il le renvoie tel quel à l’étape (b), c’est tout.
target_link_uriouiL’URL finale que la plateforme veut lancer. Souvent identique à la launch URL, mais peut pointer vers une ressource précise de l’outil (deep link). Attention : à ce stade elle n’est pas signée ; la valeur qui fait foi est le claim target_link_uri dans le JWT de l’étape (c).
lti_message_hintnon (mais Moodle l’envoie toujours)Valeur opaque propre à la plateforme, qui lui permet de retrouver le contexte du launch (quel message, quelle activité). Moodle y encode typiquement un JSON avec le cmid (course module id) et un identifiant de launch stocké en session. À renvoyer verbatim à l’étape (b).
client_idnon selon OIDC, mais requis en pratiqueLe client_id que la plateforme a attribué à l’outil lors de l’enregistrement. Indispensable quand un même iss héberge plusieurs enregistrements du même outil : il lève l’ambiguïté.
lti_deployment_idnonLe deployment concerné. Permet à l’outil multi-tenant de router avant même le launch.

⚠️ Piège : login_hint et lti_message_hint sont des valeurs opaques. Ne les parsez pas, ne les validez pas sémantiquement, ne les stockez pas comme identité : renvoyez-les à l’identique dans l’authorization request. Le format interne (l’id utilisateur Moodle, le JSON du hint) est un détail d’implémentation de Moodle qui peut changer d’une version à l’autre. La seule identité qui fait foi est le claim sub du JWT signé.

⚠️ Piège : votre endpoint /api/lti/login doit accepter GET et POST. La spec IMS l’exige explicitement, et selon le placement (lien dans le cours, lancement depuis une app mobile, content-item), Moodle peut utiliser l’un ou l’autre. Dans Next.js App Router, exportez export async function GET(...) et export async function POST(...) depuis la même route.

Étape (b) — L’outil répond par l’authorization request

QuizLab reçoit la login initiation, retrouve la plateforme via iss (+ client_id), puis :

  1. génère un state aléatoire (≥ 128 bits d’entropie) et le lie au navigateur — typiquement en le posant dans un cookie signé/chiffré SameSite=None; Secure; HttpOnly (ou en stockant un hash côté serveur) ;
  2. génère un nonce aléatoire et le stocke côté serveur avec un TTL court (5 minutes suffisent) et un flag « non utilisé » ;
  3. redirige le navigateur vers l’authorization endpoint de Moodle.

La réponse HTTP brute :

HTTP/1.1 302 Found Location: https://moodle.example.com/mod/lti/auth.php?scope=openid&response_type=id_token&response_mode=form_post&prompt=none&client_id=a1b2c3d4e5f6g7h&redirect_uri=https%3A%2F%2Ftool.example.com%2Fapi%2Flti%2Flaunch&login_hint=42&lti_message_hint=%7B%22cmid%22%3A17%2C%22launchid%22%3A%22ltilaunch4_9f2c1a%22%7D&state=s_9b1dd57ac8f24e6a8a1f&nonce=n_4f6a2c81be5d40d3bb7e Set-Cookie: lti_state=s_9b1dd57ac8f24e6a8a1f; Path=/; Secure; HttpOnly; SameSite=None; Max-Age=300

Les paramètres de l’authorization request, un par un — chacun a une valeur imposée par la spec LTI :

ParamètreValeurPourquoi
scopeopenidToujours et uniquement openid. LTI n’utilise pas les scopes OIDC profile/email : les données utilisateur arrivent via les réglages de confidentialité de la plateforme, pas via des scopes.
response_typeid_tokenFlux implicite : on demande directement l’id_token, pas de code intermédiaire ni de token endpoint pour le launch. Le JWT EST le message.
response_modeform_postLa plateforme renverra l’id_token en POST de formulaire, pas dans le fragment d’URL. Voir l’encadré ci-dessous.
promptnoneLa plateforme ne doit afficher aucune UI d’authentification ou de consentement : l’utilisateur a déjà une session Moodle. Si la session est absente/expirée, Moodle échoue (l’utilisateur voit une erreur / doit se reconnecter) au lieu de présenter un écran de login au milieu d’un iframe.
client_ida1b2c3d4e5f6g7hCelui reçu à l’étape (a) / connu de la configuration.
redirect_urihttps://tool.example.com/api/lti/launchDoit être une des redirect URIs pré-enregistrées côté Moodle pour cet outil. Moodle la valide par comparaison exacte avant d’envoyer quoi que ce soit.
login_hint42Renvoyé verbatim. Moodle vérifiera qu’il correspond bien à l’utilisateur de la session courante (défense contre la substitution de session).
lti_message_hint(verbatim)Permet à Moodle de retrouver quel message construire (quel cours, quelle activité, quel type de message).
states_9b1dd57ac8f24e6a8a1fGénéré par l’outil, jamais mis dans le JWT : il revient en paramètre POST à côté de l’id_token. C’est le lien anti-CSRF avec le cookie posé à cette étape.
noncen_4f6a2c81be5d40d3bb7eGénéré par l’outil, il voyage dans le JWT (claim nonce) : c’est l’anti-rejeu.

Pourquoi form_post et pas le fragment #id_token=... du flux implicite classique ? Trois raisons :

  1. Taille : un id_token LTI pèse facilement 3 à 8 Ko une fois encodé (contexte, rôles, endpoints de services, données custom…). Les URLs ont des limites pratiques (~2 Ko sur certains navigateurs/proxys) ; un body de formulaire n’en a pas.
  2. Fuite : tout ce qui est dans une URL finit dans l’historique du navigateur, les logs de proxys, les headers Referer. Un JWT contenant nom, email et rôles de l’utilisateur n’a rien à y faire.
  3. Serveur-first : le fragment #... n’est jamais envoyé au serveur — il faudrait du JavaScript côté outil pour le récupérer et le re-poster. Le form_post livre le JWT directement au backend de l’outil en une requête POST, ce qui colle parfaitement à une architecture où toute la crypto se fait côté serveur.

state vs nonce — les deux gardiens, chacun sa porte. C’est la question d’entretien LTI par excellence, alors clarifions une bonne fois :

  • state protège contre le CSRF : il garantit que le POST qui arrive sur /api/lti/launch est bien la suite d’un flux que ce navigateur-là a commencé chez nous. Mécanique : à l’étape (b), l’outil pose un cookie contenant state (ou un dérivé) et met state dans l’authorization request. À l’étape (d), il compare le state du POST au cookie. Un attaquant qui forgerait un launch (ou rejouerait celui d’une victime dans le navigateur d’une autre) n’aurait pas le bon cookie. Le state n’est pas dans le JWT : Moodle le recrache tel quel, sans le signer.
  • nonce protège contre le rejeu du JWT : l’outil l’a généré, Moodle l’a copié dans l’id_token signé (claim nonce), et l’outil vérifie (1) que c’est bien un nonce qu’il a émis récemment et (2) qu’il n’a jamais été consommé. Même si un attaquant intercepte un id_token valide (logs, XSS ailleurs, etc.), il ne peut pas le rejouer : le nonce est déjà brûlé.

💡 Pour un dev React : le cookie state avec SameSite=None; Secure est non négociable si votre outil est affiché dans un iframe Moodle : le POST final de l’étape (c) est une requête cross-site, et sans SameSite=None le navigateur ne joindra pas votre cookie. Ajoutez à cela les restrictions Safari/ITP sur les cookies tiers : les librairies LTI sérieuses (ltijs, la lib IMS de référence) implémentent un fallback où le state est aussi vérifiable sans cookie (stockage serveur + liaison au nonce), et la spec recommande de tenter une ouverture en nouvel onglet si les cookies tiers sont bloqués. Testez votre launch dans Safari, pas seulement dans Chrome.

Étape (c) — Moodle authentifie et poste l’id_token

Le navigateur suit la redirection vers mod/lti/auth.php, avec son cookie MoodleSession. Moodle :

  1. valide la session (sinon, échec prompt=none) ;
  2. vérifie que login_hint correspond à l’utilisateur en session et que redirect_uri figure dans les URIs enregistrées pour ce client_id (comparaison exacte) ;
  3. reconstruit le contexte du launch grâce à lti_message_hint ;
  4. applique les réglages de confidentialité de l’outil (partager le nom ? l’email ?) ;
  5. assemble l’id_token, le signe en RS256 avec sa clé privée (la clé publique correspondante étant publiée sur mod/lti/certs.php), et renvoie une page HTML minimale :
HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 <form action="https://tool.example.com/api/lti/launch" method="post"> <input type="hidden" name="id_token" value="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjY0YWYxZTdjOWIifQ.eyJpc3MiOiJodHRwczovL21vb2RsZS5leGFtcGxlLmNvbSIsIC4uLn0.SflKxwRJSMeKKF2QT4..."/> <input type="hidden" name="state" value="s_9b1dd57ac8f24e6a8a1f"/> </form> <script>document.forms[0].submit();</script>

Le navigateur auto-soumet, et QuizLab reçoit :

POST /api/lti/launch HTTP/1.1 Host: tool.example.com Content-Type: application/x-www-form-urlencoded Cookie: lti_state=s_9b1dd57ac8f24e6a8a1f id_token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjY0YWYxZTdjOWIifQ.eyJpc3MiOi...&state=s_9b1dd57ac8f24e6a8a1f

Étape (d) — L’outil valide et crée sa session

QuizLab exécute la checklist complète de la section 4, puis — et seulement si tout passe — crée sa propre session applicative (son propre cookie, son propre JWT de session, ce que vous voulez) et rend la ressource. À partir de là, le launch est terminé : la session de l’outil vit sa vie, indépendamment de la session Moodle.

⚠️ Piège : ne confondez pas la durée de validité du launch et la durée de votre session applicative. L’id_token expire au bout de quelques minutes (exp) : il sert à établir la session, pas à être la session. Inversement, ne créez pas une session de 30 jours à partir d’un launch : l’utilisateur peut avoir été désinscrit du cours entre-temps. Une session de la durée d’un cours (quelques heures) liée au couple (utilisateur, resource_link) est un bon compromis ; pour toute décision sensible ultérieure (afficher les notes de toute la classe), re-vérifiez via NRPS.


3. Anatomie complète de l’id_token

Voici un id_token intégral et réaliste tel que Moodle 5.2 l’émet pour un launch d’activité classique. D’abord le header JOSE, puis le payload.

Header JWT — id_token émis par Moodle :

{ "typ": "JWT", "alg": "RS256", "kid": "64af1e7c9b" }

Le kid (key id) désigne la clé publique à utiliser dans le JWKS de la plateforme (https://moodle.example.com/mod/lti/certs.php). Moodle publie un document JWKS standard :

{ "keys": [ { "kty": "RSA", "alg": "RS256", "use": "sig", "kid": "64af1e7c9b", "e": "AQAB", "n": "sXchQvVYxYzR8lHc2aTLyS5n3sL1kQxk3v0dV2xTgJZ9y1uNfE0qGz4bH7pXn8mWZK4dQeS0aY3cJvNwR2tUuT0FhLg4vB1kM9nE6xP7wDq5cR8sT2yU3vW4xY5zA6bC7dE8fG9hI0jK1lM2nO3pQ4rS5tU6vW7xY8zA9bC0dE1fG2hI3jK4lM5nO6pQ7rS8tU9vW0xY1zA2bC3dE4fG5hI6jK7lM8nO9pQ0rS1tU2vW3xY4zA5bC6dE7fG8hI9jK0lQ" } ] }

Payload — id_token décodé — LtiResourceLinkRequest émis par Moodle :

{ "iss": "https://moodle.example.com", "aud": "a1b2c3d4e5f6g7h", "sub": "9d3c7f2e-51a4-4b8e-a2f1-6c0d8e9b4a17", "exp": 1782900360, "iat": 1782900300, "nonce": "n_4f6a2c81be5d40d3bb7e", "azp": "a1b2c3d4e5f6g7h", "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiResourceLinkRequest", "https://purl.imsglobal.org/spec/lti/claim/version": "1.3.0", "https://purl.imsglobal.org/spec/lti/claim/deployment_id": "1", "https://purl.imsglobal.org/spec/lti/claim/target_link_uri": "https://tool.example.com/api/lti/launch", "https://purl.imsglobal.org/spec/lti/claim/resource_link": { "id": "17", "title": "Quiz de mi-semestre — Réseaux", "description": "QCM noté sur les chapitres 1 à 5" }, "https://purl.imsglobal.org/spec/lti/claim/context": { "id": "31", "label": "RES-101", "title": "Introduction aux réseaux informatiques", "type": ["CourseSection"] }, "https://purl.imsglobal.org/spec/lti/claim/roles": [ "http://purl.imsglobal.org/vocab/lis/v2/membership#Learner", "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student" ], "https://purl.imsglobal.org/spec/lti/claim/custom": { "quiz_mode": "exam", "attempt_limit": "2", "moodle_user_id": "42" }, "https://purl.imsglobal.org/spec/lti/claim/launch_presentation": { "document_target": "iframe", "return_url": "https://moodle.example.com/mod/lti/return.php?course=31&launch_container=3&instanceid=17&sesskey=xYz123AbC", "locale": "fr", "height": 600, "width": 900 }, "https://purl.imsglobal.org/spec/lti/claim/tool_platform": { "guid": "moodle.example.com-7c1a9e4d2b", "name": "Université Exemple — Moodle", "product_family_code": "moodle", "version": "2026042000", "url": "https://moodle.example.com" }, "https://purl.imsglobal.org/spec/lti/claim/lis": { "person_sourcedid": "etu-2026-00842", "course_section_sourcedid": "RES-101-S1-2026" }, "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint": { "scope": [ "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem", "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly", "https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly", "https://purl.imsglobal.org/spec/lti-ags/scope/score" ], "lineitems": "https://moodle.example.com/mod/lti/services.php/31/lineitems?type_id=3", "lineitem": "https://moodle.example.com/mod/lti/services.php/31/lineitems/12/lineitem?type_id=3" }, "https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice": { "context_memberships_url": "https://moodle.example.com/mod/lti/services.php/CourseSection/31/bindings/3/memberships", "service_versions": ["1.0", "2.0"] }, "name": "Alex Lemia", "given_name": "Alex", "family_name": "Lemia", "email": "alexlemia13@gmail.com", "locale": "fr", "picture": "https://moodle.example.com/pluginfile.php/55/user/icon/boost/f1?rev=1204" }

Passons chaque bloc en revue.

3.1 Les claims OIDC de base

ClaimExempleSignification et validation
isshttps://moodle.example.comL’émetteur. Doit être exactement l’issuer enregistré pour cette plateforme. Pas de normalisation laxiste : http://https://, slash final ≠ pas de slash.
auda1b2c3d4e5f6g7hL’audience = le client_id de l’outil. Peut être une chaîne ou un tableau de chaînes selon la spec JWT — votre code doit gérer les deux. Si votre client_id n’y est pas → rejet.
azpa1b2c3d4e5f6g7hAuthorized party. Obligatoire à vérifier si aud contient plusieurs valeurs : azp doit alors être égal à votre client_id. Moodle l’envoie même avec un aud simple.
sub9d3c7f2e-51a4-...L’identifiant stable et unique de l’utilisateur au sein de cet issuer. C’est LA clé d’identité côté outil : indexez vos utilisateurs par le triplet (iss, deployment_id éventuellement, sub) — jamais par email (modifiable, parfois absent) ni par name. Moodle n’expose pas son id interne brut ici quand la confidentialité l’exige. Pour un launch anonyme (privacy maximale), sub peut être absent… mais alors le message doit être traité comme anonyme, pas rejeté silencieusement ni doté d’une identité inventée.
exp1782900360Expiration (epoch, secondes). Après ça, JWT mort. Moodle émet des tokens à durée de vie courte (60 s).
iat1782900300Date d’émission. Rejetez un iat dans le futur (au-delà de la tolérance d’horloge).
noncen_4f6a2c81be5d40d3bb7eLe nonce que VOUS avez généré à l’étape (b). Vérifiez : émis par vous, non expiré, jamais consommé — puis brûlez-le.
  • https://purl.imsglobal.org/spec/lti/claim/message_type : le type de message. Pour un launch d’activité : LtiResourceLinkRequest. Les autres valeurs (section 6) : LtiDeepLinkingRequest, LtiSubmissionReviewRequest. Votre routeur de launch commence par lire ce claim.
  • https://purl.imsglobal.org/spec/lti/claim/version : toujours la chaîne "1.3.0". Toute autre valeur → rejet.
  • https://purl.imsglobal.org/spec/lti/claim/deployment_id : identifie le déploiement de l’outil sur la plateforme. Chez Moodle, chaque « outil externe » configuré (au niveau site ou cours) reçoit un deployment_id (ici "1"). Le triplet (issuer, client_id, deployment_id) est l’identité complète d’une intégration ; l’outil doit vérifier que ce deployment_id lui a bien été déclaré. C’est ce qui empêche un admin malveillant d’un autre déploiement de la même plateforme de se faire passer pour le vôtre.
  • https://purl.imsglobal.org/spec/lti/claim/target_link_uri : l’URL effectivement demandée. Contrairement au paramètre homonyme de l’étape (a), celle-ci est signée : c’est elle qui fait foi. Si votre outil sert plusieurs ressources sous des chemins différents, routez sur ce claim (après avoir vérifié qu’il pointe bien chez vous).
"https://purl.imsglobal.org/spec/lti/claim/resource_link": { "id": "17", "title": "Quiz de mi-semestre — Réseaux", "description": "QCM noté sur les chapitres 1 à 5" }

Le resource link est l’instance de placement de l’outil : cette activité-là, dans ce cours-là. id est obligatoire, stable, unique dans le déploiement — chez Moodle c’est l’id de l’instance lti en base. Deux activités QuizLab dans le même cours = deux resource_link.id différents, même context.id. C’est la granularité naturelle pour rattacher un état côté outil (« le quiz configuré pour ce placement »).

3.4 context — le cours

"https://purl.imsglobal.org/spec/lti/claim/context": { "id": "31", "label": "RES-101", "title": "Introduction aux réseaux informatiques", "type": ["CourseSection"] }

Le contexte est l’espace d’apprentissage : chez Moodle, le cours. id est l’id du cours (stable, unique par déploiement) ; label est le shortname, title le fullname. type est un tableau dont les valeurs viennent du vocabulaire LIS v2 — soit en forme courte (CourseSection, ce que Moodle envoie), soit en URI complète :

  • http://purl.imsglobal.org/vocab/lis/v2/course#CourseTemplate — un gabarit de cours ;
  • http://purl.imsglobal.org/vocab/lis/v2/course#CourseOffering — une occurrence de cours (le cours « offert » ce semestre) ;
  • http://purl.imsglobal.org/vocab/lis/v2/course#CourseSection — une section/groupe-classe de cette occurrence ;
  • http://purl.imsglobal.org/vocab/lis/v2/course#Group — un groupe de travail.

Moodle assimile ses cours à des CourseSection. Traitez les formes courtes et longues comme équivalentes lors du parsing.

3.5 roles — qui est cette personne ici ?

"https://purl.imsglobal.org/spec/lti/claim/roles": [ "http://purl.imsglobal.org/vocab/lis/v2/membership#Learner", "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student" ]

Les rôles sont des URIs complètes (le claim est obligatoire, mais le tableau peut être vide). Trois familles, à ne pas mélanger :

Rôles de contexte (dans CE cours — base http://purl.imsglobal.org/vocab/lis/v2/membership#) — les seuls sur lesquels fonder vos autorisations pédagogiques :

URISignification
http://purl.imsglobal.org/vocab/lis/v2/membership#LearnerÉtudiant du cours
http://purl.imsglobal.org/vocab/lis/v2/membership#InstructorEnseignant du cours
http://purl.imsglobal.org/vocab/lis/v2/membership#ContentDeveloperConcepteur de contenu
http://purl.imsglobal.org/vocab/lis/v2/membership#MentorMentor/parent (voir aussi le claim role_scope_mentor)
http://purl.imsglobal.org/vocab/lis/v2/membership#AdministratorAdmin du contexte

Sous-rôles de contexte : ils précisent un rôle principal avec la syntaxe rôle#SousRôle, par exemple http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#TeachingAssistant (assistant d’enseignement). La spec impose : quand une plateforme envoie un sous-rôle, elle envoie aussi le rôle parent (membership#Instructor ici). Côté outil, la stratégie robuste est d’ignorer les sous-rôles inconnus et de décider sur le rôle parent.

Rôles d’institution (base http://purl.imsglobal.org/vocab/lis/v2/institution/person#) : #Student, #Faculty, #Staff, #Guest, #Alum… Ils décrivent la personne dans l’établissement, pas dans le cours. Un enseignant (institution/person#Faculty) peut être membership#Learner dans un cours de formation continue.

Rôles système (base http://purl.imsglobal.org/vocab/lis/v2/system/person#) : #Administrator, #SysAdmin, #User, #None. Un admin Moodle qui lance l’outil depuis un cours portera à la fois system/person#Administrator et son rôle de contexte.

⚠️ Piège : ne donnez JAMAIS des droits d’enseignant dans votre outil à quelqu’un au motif qu’il est institution/person#Faculty ou system/person#Administrator. Le seul rôle qui dit « cette personne peut corriger CE quiz » est membership#Instructor dans le contexte du launch. Confondre les familles de rôles est l’un des bugs d’autorisation les plus fréquents des outils LTI. Comparez les URIs complètes, pas un endsWith("#Instructor") qui matcherait aussi un sous-rôle exotique.

3.6 custom — les paramètres personnalisés

"https://purl.imsglobal.org/spec/lti/claim/custom": { "quiz_mode": "exam", "attempt_limit": "2", "moodle_user_id": "42" }

Fusion des paramètres custom définis (a) dans l’enregistrement de l’outil côté Moodle, (b) dans l’instance d’activité, (c) renvoyés par un Deep Linking Response. Les variables de substitution LTI y sont résolues par Moodle avant l’envoi : si l’admin a configuré moodle_user_id=$User.id, l’outil reçoit la valeur résolue ("42"). Toutes les valeurs sont des chaînes (parsez vos entiers vous-même). Une variable non supportée est renvoyée telle quelle ("$Bidule.inconnu") : votre code doit tolérer les valeurs commençant par $.

3.7 launch_presentation — comment l’outil est affiché

"https://purl.imsglobal.org/spec/lti/claim/launch_presentation": { "document_target": "iframe", "return_url": "https://moodle.example.com/mod/lti/return.php?course=31&launch_container=3&instanceid=17&sesskey=xYz123AbC", "locale": "fr", "height": 600, "width": 900 }
  • document_target : iframe ou window. En iframe, pensez cookies tiers, X-Frame-Options/frame-ancestors (votre outil doit autoriser l’embarquement par l’origine Moodle) et absence de target="_blank" sauvage.
  • return_url : où renvoyer l’utilisateur quand il « quitte » l’outil. Vous pouvez y accrocher des query params normalisés : lti_errormsg, lti_msg, lti_errorlog, lti_log pour faire afficher un message par Moodle au retour.
  • locale : la langue de l’UI de l’utilisateur dans Moodle (fr). Servez la même.
  • height/width : dimensions suggérées du conteneur.

💡 Pour un dev React : document_target: "iframe" signifie que votre app Next.js tourne dans un iframe cross-origin. Conséquences concrètes : (1) configurez Content-Security-Policy: frame-ancestors https://moodle.example.com plutôt que X-Frame-Options ; (2) votre cookie de session doit être SameSite=None; Secure, sinon chaque navigation interne perdra la session ; (3) window.top.location vous est interdit (cross-origin) — pour rediriger le parent, utilisez le return_url ; (4) évitez localStorage pour l’état critique : Safari le partitionne en contexte tiers.

3.8 tool_platform — la carte d’identité de la plateforme

"https://purl.imsglobal.org/spec/lti/claim/tool_platform": { "guid": "moodle.example.com-7c1a9e4d2b", "name": "Université Exemple — Moodle", "product_family_code": "moodle", "version": "2026042000", "url": "https://moodle.example.com" }

guid identifie l’instance de plateforme de façon stable (utile pour le multi-tenant analytique) ; product_family_code vaut "moodle" (pratique pour activer des adaptations spécifiques) ; version est la version Moodle au format release (ici la build de Moodle 5.2). Ne fondez aucune décision de sécurité sur ce claim purement informatif.

3.9 lis — les identifiants du SI de l’établissement

"https://purl.imsglobal.org/spec/lti/claim/lis": { "person_sourcedid": "etu-2026-00842", "course_section_sourcedid": "RES-101-S1-2026" }

Les sourcedid sont les identifiants issus du système d’information de l’établissement (le « numéro étudiant », le code cours du SI scolarité) — chez Moodle, les champs ID number de l’utilisateur et du cours. Souvent vides si l’établissement ne les renseigne pas. Utiles pour réconcilier avec un SI externe, jamais comme clé primaire (non garantis uniques ni stables par la spec).

3.10 Les claims utilisateur OIDC standard — sous contrôle de la confidentialité

"name": "Alex Lemia", "given_name": "Alex", "family_name": "Lemia", "email": "alexlemia13@gmail.com", "locale": "fr", "picture": "https://moodle.example.com/pluginfile.php/55/user/icon/boost/f1?rev=1204"

Ces claims (issus de la « standard claims list » OIDC) ne sont présents que si la configuration de l’outil dans Moodle l’autorise : les réglages « Partager le nom de l’utilisateur avec l’outil » et « Partager l’adresse de courriel avec l’outil » (onglet Confidentialité de la configuration d’outil externe) pilotent respectivement name/given_name/family_name et email. Votre outil doit fonctionner quand ils sont absents : affichez « Participant » plutôt que de planter, et n’utilisez jamais l’email comme identifiant (c’est sub qui identifie).

📚 Aller plus loin : la liste normative des claims et leur cardinalité exacte se trouvent dans LTI 1.3 Core Specification (1edtech.org/standards/lti — section « Message payloads »), et la correspondance côté Moodle dans le code de mod/lti/locallib.php (fonctions de construction du JWT) ainsi que sur moodledev.io (documentation LTI). Les vocabulaires de rôles sont définis dans l’annexe « Role vocabularies » de la spec Core : c’est la seule source fiable pour les URIs.


4. Validation côté outil : la checklist normative

Voici, dans l’ordre recommandé, tout ce que QuizLab doit vérifier en recevant le POST de launch. Chaque ligne est obligatoire ; l’échec d’une seule = launch rejeté.

Checklist de validation d'un launch LTI 1.3 (côté outil) ───────────────────────────────────────────────────────── 1. [transport] La requête est un POST en HTTPS avec id_token et state présents. 2. [state] state == valeur liée au navigateur (cookie posé à l'étape b, ou store serveur). Sinon : 401, aucun traitement du JWT. 3. [format] id_token = 3 segments base64url ; header parseable ; header.alg == "RS256" (rejeter "none", rejeter HS*). 4. [clé] header.kid présent → chercher la clé dans le JWKS de la plateforme (cache) ; si kid inconnu → RE-FETCH du JWKS (rotation possible) ; si toujours inconnu → rejet. 5. [signature] Vérification RS256 avec la clé publique trouvée. 6. [iss] iss == issuer enregistré (comparaison exacte de chaînes). 7. [aud] aud (string ou array) contient notre client_id ; si aud est multiple → azp obligatoire et == client_id. 8. [temps] now < exp + skew ET iat <= now + skew (skew recommandé : 60 s, max quelques minutes). 9. [nonce] nonce présent, émis par nous, non expiré, JAMAIS consommé → le marquer consommé atomiquement (SETNX + TTL en Redis). 10. [type] claim message_type présent et supporté (LtiResourceLinkRequest, LtiDeepLinkingRequest, ...). 11. [version] claim version == "1.3.0". 12. [deploy] claim deployment_id présent ET enregistré pour ce couple (iss, client_id). 13. [sub] sub présent (sauf launch anonyme explicitement supporté). 14. [spécifique] Selon message_type : resource_link.id présent pour un LtiResourceLinkRequest ; deep_linking_settings présent pour un LtiDeepLinkingRequest ; etc. ───────────────────────────────────────────────────────── → Tout est vert : créer la session outil, brûler state et nonce.

Quelques précisions de comportement en cas d’échec :

  • Ne détaillez pas la cause côté client. Répondez 401 avec un message générique (« Launch invalide, relancez depuis votre cours ») et loggez le détail côté serveur. Dire « signature invalide » vs « nonce rejoué » à l’attaquant, c’est lui offrir un oracle.
  • state d’abord, crypto ensuite. Vérifier le state avant de toucher au JWT coûte une comparaison de chaînes et coupe court aux POST forgés — inutile de payer une vérification RSA pour un CSRF.
  • Le re-fetch JWKS sur kid inconnu doit être rate-limité (par exemple 1 re-fetch/minute/issuer) : sinon un attaquant peut faire marteler certs.php par votre serveur avec des JWT bidons.
  • La consommation du nonce doit être atomique. Deux POST simultanés avec le même id_token (double-clic, replay racé) ne doivent donner qu’une seule session. En Redis : SET nonce:<valeur> 1 NX EX 300 — si la commande répond « déjà présent », rejetez.

⚠️ Piège : la plupart des librairies JWT (jose, jsonwebtoken, PyJWT…) vérifient signature + exp + iat + aud + iss si vous le leur demandez… mais aucune ne connaît deployment_id, message_type, version, ni votre store de nonces. Les étapes 9 à 14 sont à votre charge, toujours. Les certifications IMS échouent massivement sur ces validations « métier » que les devs croient couvertes par la lib.

💡 Pour un dev React : avec la librairie jose (celle qu’utilise NextAuth), les étapes 3 à 8 tiennent en dix lignes dans un Route Handler :

// app/api/lti/launch/route.ts — extrait de validation import \{ createRemoteJWKSet, jwtVerify \} from "jose"; const jwks = createRemoteJWKSet( new URL("https://moodle.example.com/mod/lti/certs.php") ); // gère le cache ET le re-fetch sur kid inconnu const \{ payload \} = await jwtVerify(idToken, jwks, \{ issuer: "https://moodle.example.com", audience: "a1b2c3d4e5f6g7h", algorithms: ["RS256"], clockTolerance: 60, \}); // Reste À FAIRE : state, nonce (store), azp si aud multiple, // message_type, version "1.3.0", deployment_id, sub.

5. Les clés : qui signe quoi, qui vérifie avec quoi

LTI 1.3 fait vivre deux paires de clés RSA indépendantes, une par acteur. C’est le point que les débutants mélangent le plus, alors posons-le à plat.

5.1 La paire de clés de la plateforme (Moodle)

Moodle génère sa paire RSA à l’installation (visible dans l’administration des outils LTI). Sa clé privée signe tous les JWT que Moodle émet : les id_token de launch et les access tokens de service. Sa clé publique est publiée au format JWKS sur https://moodle.example.com/mod/lti/certs.php, avec un kid. L’outil ne stocke jamais cette clé en dur : il la récupère (et la met en cache) via l’URL JWKS, ce qui permet à Moodle de faire tourner ses clés sans casser les intégrations.

5.2 La paire de clés de l’outil (QuizLab)

QuizLab génère sa propre paire RSA (2048 bits minimum, 4096 recommandé pour une rotation moins fréquente). Sa clé privée — qui ne quitte JAMAIS son serveur (variable d’environnement chiffrée, KMS, HSM…) — sert exactement à deux choses :

  1. signer les client_assertion OAuth2 envoyées au token endpoint de Moodle (section 7.1) ;
  2. signer les JWT LtiDeepLinkingResponse renvoyés à Moodle après une sélection de contenu (section 6.2).

Sa clé publique est exposée en JWKS sur https://tool.example.com/api/lti/jwks :

{ "keys": [ { "kty": "RSA", "alg": "RS256", "use": "sig", "kid": "quizlab-2026-01", "e": "AQAB", "n": "wJdPqR8sT2yU3vW4xY5zA6bC7dE8fG9hI0jK1lM2nO3pQ4rS5tU6vW7xY8zA9bC0dE1fG2hI3jK4lM5nO6pQ7rS8tU9vW0xY1zA2bC3dE4fG5hI6jK7lM8nO9pQ0rS1tU2vW3xY4zA5bC6dE7fG8hI9jK0lM1nO2pQ3rS4tU5vW6xY7zA8bC9dE0fG1hI2jK3lM4nO5pQ6rS7tU8vW9xY0zA1bC2dE3fG4hQ" } ] }

Cette URL est déclarée dans Moodle lors de l’enregistrement de l’outil (« URL du jeu de clés publiques ») ; Moodle s’en sert pour vérifier les signatures venant de l’outil. Moodle accepte aussi une clé publique RSA collée en dur dans la config, mais l’URL JWKS est le seul mode qui permette la rotation : publiez la nouvelle clé avec un nouveau kid à côté de l’ancienne, basculez la signature, retirez l’ancienne après expiration des derniers JWT signés avec.

5.3 Le tableau à connaître par cœur

ArtefactSigné parAvecVérifié parAvec
id_token de launch (tous message types émis par la plateforme)Moodleclé privée plateformeQuizLabJWKS plateforme (/mod/lti/certs.php)
client_assertion (demande d’access token)QuizLabclé privée outilMoodleJWKS outil (/api/lti/jwks)
JWT LtiDeepLinkingResponseQuizLabclé privée outilMoodleJWKS outil (/api/lti/jwks)
access_token de serviceMoodle (opaque ou JWT, au choix de la plateforme)clé/secret plateformeMoodle lui-même à chaque appel RESTinterne

Règle mnémotechnique : chacun signe ce qu’il émet avec SA clé privée ; chacun vérifie ce qu’il reçoit avec le JWKS de L’AUTRE. L’access token est un cas à part : c’est un ticket opaque du point de vue de l’outil (ne le décodez pas, ne supposez rien de son format), seul Moodle le comprend.

⚠️ Piège : ne servez jamais votre JWKS depuis un endpoint qui exige une authentification ou qui est bloqué par un WAF trop zélé : Moodle doit pouvoir le lire en GET anonyme, y compris depuis un cron serveur. Et surtout, gardez le même kid pour la même clé dans la durée — changer le kid sans changer la clé (ou l’inverse) est la cause n°1 des « Invalid client_assertion » intermittents après un redéploiement où la paire est régénérée à la volée au lieu d’être persistée.


6. Les types de messages

Le claim message_type route tout. Moodle 5.2 émet principalement trois types.

6.1 LtiResourceLinkRequest — le launch classique

C’est l’exemple intégral de la section 3 : l’utilisateur ouvre une activité déjà placée dans le cours. Le claim spécifique obligatoire est https://purl.imsglobal.org/spec/lti/claim/resource_link (avec id). C’est le seul message type où l’outil doit afficher la ressource ; les autres sont des dialogues.

6.2 Deep Linking : LtiDeepLinkingRequestLtiDeepLinkingResponse

Le Deep Linking (anciennement Content-Item Message) résout un vrai problème UX : sans lui, l’enseignant doit copier-coller des URLs et des paramètres custom de l’outil vers Moodle. Avec lui, l’enseignant clique « Sélectionner un contenu », l’outil ouvre son propre picker (votre belle UI React), l’enseignant choisit « Quiz de mi-semestre », et l’outil renvoie à Moodle une description machine du contenu choisi — Moodle crée l’activité tout seul, ligne de note comprise.

C’est un aller-retour : un launch normal dans un sens, puis un JWT signé par l’outil dans l’autre.

Le claim de la requête — présent dans l’id_token quand message_type vaut LtiDeepLinkingRequest :

// Extrait de l'id_token — LtiDeepLinkingRequest émis par Moodle { "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiDeepLinkingRequest", "https://purl.imsglobal.org/spec/lti/claim/version": "1.3.0", "https://purl.imsglobal.org/spec/lti/claim/deployment_id": "1", "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings": { "deep_link_return_url": "https://moodle.example.com/mod/lti/contentitem_return.php?course=31&id=3&sesskey=xYz123AbC", "accept_types": ["ltiResourceLink"], "accept_presentation_document_targets": ["iframe", "window"], "accept_media_types": "image/*,text/html", "accept_multiple": true, "auto_create": false, "title": "Introduction aux réseaux informatiques", "text": "Sélectionnez un ou plusieurs quiz à ajouter au cours", "data": "dl_op_7c4f19e2ab" } }

Champ par champ :

  • deep_link_return_url : l’URL Moodle où poster la réponse. Faites-lui confiance uniquement parce qu’elle arrive dans un JWT signé — c’est toute la différence avec un paramètre de formulaire.
  • accept_types : les types de content items acceptés. Moodle accepte ltiResourceLink (le principal), et selon le contexte link, file, html, image. Ne renvoyez QUE des types listés ici.
  • accept_presentation_document_targets : iframe, window, embed — comment le contenu pourra être affiché.
  • accept_multiple : si true, vous pouvez renvoyer plusieurs items (Moodle créera plusieurs activités).
  • auto_create : si true, la plateforme créera les ressources sans écran de confirmation.
  • data : valeur opaque à renvoyer verbatim dans la réponse. Moodle s’en sert pour corréler la réponse à la requête (anti-CSRF du retour). Si data est présent dans la requête, il est obligatoire dans la réponse.

La réponse — un JWT construit et signé par QuizLab (clé privée de l’outil, kid présent dans son JWKS), renvoyé en application/x-www-form-urlencoded dans un champ nommé JWT :

// JWT décodé — LtiDeepLinkingResponse émis par QuizLab (signé clé privée outil) { "iss": "a1b2c3d4e5f6g7h", "aud": "https://moodle.example.com", "exp": 1782900660, "iat": 1782900600, "nonce": "dlresp_8e1b6f3a9c", "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiDeepLinkingResponse", "https://purl.imsglobal.org/spec/lti/claim/version": "1.3.0", "https://purl.imsglobal.org/spec/lti/claim/deployment_id": "1", "https://purl.imsglobal.org/spec/lti-dl/claim/data": "dl_op_7c4f19e2ab", "https://purl.imsglobal.org/spec/lti-dl/claim/content_items": [ { "type": "ltiResourceLink", "url": "https://tool.example.com/api/lti/launch", "title": "Quiz de mi-semestre — Réseaux", "text": "QCM noté sur les chapitres 1 à 5", "custom": { "quiz_id": "qz_58c21f", "quiz_mode": "exam" }, "lineItem": { "scoreMaximum": 20, "label": "Quiz de mi-semestre — Réseaux", "resourceId": "qz_58c21f", "tag": "midterm" }, "iframe": { "width": 900, "height": 600 }, "available": { "startDateTime": "2026-09-01T08:00:00Z", "endDateTime": "2026-09-15T23:59:00Z" }, "submission": { "endDateTime": "2026-09-15T23:59:00Z" } } ], "https://purl.imsglobal.org/spec/lti-dl/claim/msg": "1 quiz ajouté au cours." }

Notez le renversement des identités : dans ce JWT, iss = le client_id de l’outil et aud = l’issuer de la plateforme. C’est logique (l’émetteur est l’outil) mais déroutant la première fois. Notez aussi :

  • custom : ces paramètres seront fusionnés dans le claim custom de tous les futurs launches de cette activité — c’est LE mécanisme pour lier un placement Moodle à une ressource interne de l’outil (quiz_id) sans base de correspondance fragile ;
  • lineItem : demande à Moodle de créer d’office une colonne de carnet de notes pour cette activité (barème sur 20). L’AGS (section 7.2) pourra ensuite y pousser des scores ;
  • les claims optionnels https://purl.imsglobal.org/spec/lti-dl/claim/msg / .../claim/log (et leurs pendants errormsg/errorlog) font afficher un message par Moodle au retour.

Le formulaire de retour, côté HTML généré par QuizLab :

<!-- Page renvoyée par QuizLab après la sélection --> <form action="https://moodle.example.com/mod/lti/contentitem_return.php?course=31&id=3&sesskey=xYz123AbC" method="post"> <input type="hidden" name="JWT" value="eyJhbGciOiJSUzI1NiIsImtpZCI6InF1aXpsYWItMjAyNi0wMSJ9.eyJpc3MiOiJhMWIyYzNkNGU1ZjZnN2giLCAuLi59.qH8sT2y..."/> </form> <script>document.forms[0].submit();</script>

⚠️ Piège : un LtiDeepLinkingRequest n’a pas de claim resource_link (il n’y a pas encore de placement !) et n’a en général pas les claims AGS/NRPS. Si votre code de launch suppose inconditionnellement resource_link.id, le deep linking plantera. Routez sur message_type AVANT d’accéder aux claims spécifiques.

6.3 LtiSubmissionReviewRequest — en bref

Ce message (spec Submission Review, liée à AGS) est émis quand un enseignant, depuis le carnet de notes Moodle, clique pour revoir le travail qui a produit un score donné. L’id_token ressemble à un launch classique mais avec message_type = LtiSubmissionReviewRequest, le claim AGS endpoint pointant le lineitem concerné, et le claim https://purl.imsglobal.org/spec/lti/claim/for_user identifiant l’étudiant dont on consulte la copie (qui n’est PAS l’utilisateur du launch !). L’outil doit alors afficher la tentative de cet étudiant en lecture seule pour l’enseignant. Retenez surtout la distinction sub (qui regarde) / for_user (qui est regardé).


7. Les services LTI Advantage

Changement de jambe : tout ce qui suit se passe sans navigateur, entre le serveur de QuizLab et le serveur Moodle.

7.1 Obtenir un access token : client_credentials + assertion JWT (RFC 7523)

Avant tout appel AGS ou NRPS, l’outil doit obtenir un access token auprès du token endpoint de Moodle. Le grant est client_credentials, mais l’authentification du client ne se fait pas par client_secret : elle se fait par une assertion JWT signée par la clé privée de l’outil (profil private_key_jwt / RFC 7523).

┌──────────────────────────┐ ┌──────────────────────────┐ │ QuizLab (serveur) │ │ Moodle (serveur) │ │ tool.example.com │ │ moodle.example.com │ └────────────┬─────────────┘ └────────────┬─────────────┘ │ │ │ 1. Construit la client_assertion (JWT ~60s) │ │ iss = sub = client_id │ │ aud = token endpoint ; jti unique │ │ signée RS256, clé privée OUTIL, kid │ │ │ │ 2. POST /mod/lti/token.php │ │ grant_type=client_credentials │ │ client_assertion_type=...jwt-bearer │ │ client_assertion=eyJ... │ │ scope=(scopes AGS/NRPS demandés) │ ├────────────────────────────────────────────────►│ │ │ 3. Résout client_id, │ │ GET JWKS de l'outil │ │ (cache), vérifie la │ │ signature, jti, exp, │ │ intersecte les scopes │ │ avec ceux ACCORDÉS │ 4. 200 { access_token, expires_in: 3600, │ à l'enregistrement │ token_type: "Bearer", scope } │ │◄────────────────────────────────────────────────┤ │ │ │ 5. Appels REST AGS/NRPS │ │ Authorization: Bearer <access_token> │ ├────────────────────────────────────────────────►│ │ 6. 200/201 JSON (lineitems, scores, members...) │ │◄────────────────────────────────────────────────┤

La client_assertion, décodée :

// client_assertion décodée — émise par QuizLab, signée avec sa clé privée (kid: quizlab-2026-01) { "iss": "a1b2c3d4e5f6g7h", "sub": "a1b2c3d4e5f6g7h", "aud": "https://moodle.example.com/mod/lti/token.php", "iat": 1782903600, "exp": 1782903660, "jti": "ca_5f8e2b1d9c3a4e7f" }

Points clés : iss et sub valent tous deux le client_id (le client parle de lui-même) ; aud est l’URL du token endpoint (la spec IMS Security Framework autorise aussi l’issuer de la plateforme — Moodle accepte les deux, mais l’URL du token endpoint est la valeur canonique) ; jti est un identifiant unique que la plateforme peut tracker pour empêcher le rejeu de l’assertion elle-même ; durée de vie courte (≤ 60 s de marge, ne fabriquez pas des assertions valides une heure).

La requête complète, en curl :

# Demande d'access token — QuizLab → Moodle curl -s -X POST "https://moodle.example.com/mod/lti/token.php" \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=client_credentials" \ --data-urlencode "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \ --data-urlencode "client_assertion=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InF1aXpsYWItMjAyNi0wMSJ9.eyJpc3MiOiJhMWIyYzNkNGU1ZjZnN2giLCJzdWIiOiJhMWIyYzNkNGU1ZjZnN2giLCJhdWQiOiJodHRwczovL21vb2RsZS5leGFtcGxlLmNvbS9tb2QvbHRpL3Rva2VuLnBocCIsImlhdCI6MTc4MjkwMzYwMCwiZXhwIjoxNzgyOTAzNjYwLCJqdGkiOiJjYV81ZjhlMmIxZDljM2E0ZTdmIn0.kX2sT9yU..." \ --data-urlencode "scope=https://purl.imsglobal.org/spec/lti-ags/scope/lineitem https://purl.imsglobal.org/spec/lti-ags/scope/score https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly"

La réponse :

// Réponse 200 de https://moodle.example.com/mod/lti/token.php { "access_token": "e5c3a1f09b8d7c6e5f4a3b2c1d0e9f8a7b6c5d4e", "token_type": "Bearer", "expires_in": 3600, "scope": "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem https://purl.imsglobal.org/spec/lti-ags/scope/score https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly" }

Le token vaut pour toute la plateforme (pas pour un contexte précis) et pour l’intersection entre les scopes demandés et les scopes accordés à l’enregistrement de l’outil dans Moodle (cases à cocher « IMS LTI Assignment and Grade Services », « IMS LTI Names and Role Provisioning » de la config d’outil). Comparez le champ scope de la réponse à votre demande : Moodle peut en avoir retiré.

💡 Pour un dev React : mettez l’access token en cache côté serveur (Redis, ou simple map en mémoire par instance) sous la clé (issuer, client_id, scopes-triés) avec un TTL de expires_in - 60. Demander un token à chaque POST de score fonctionne, mais c’est un aller-retour + une signature RSA + une vérif JWKS par appel — et sur un quiz rendu par 300 étudiants en même temps, token.php deviendra votre goulot. Attention aussi aux environnements serverless : une map en mémoire par lambda se vide à chaque cold start, préférez un cache externe.

⚠️ Piège : le paramètre scope de la requête token est une liste séparée par des espaces, à URL-encoder d’un bloc. Les scopes AGS/NRPS sont des URLs complètes : la moindre coquille (http vs https, purl.imsglobal.org vs autre chose, singulier/pluriel) donne un token sans le scope voulu, et vos appels REST répondront 401/403 de façon incompréhensible. Copiez-collez les scopes depuis ce chapitre ou la spec, ne les retapez jamais.

7.2 AGS — Assignment and Grade Services : lire et écrire des notes

Le claim endpoint et les scopes

Tout part du claim reçu au launch :

// Claim AGS dans l'id_token de launch "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint": { "scope": [ "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem", "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly", "https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly", "https://purl.imsglobal.org/spec/lti-ags/scope/score" ], "lineitems": "https://moodle.example.com/mod/lti/services.php/31/lineitems?type_id=3", "lineitem": "https://moodle.example.com/mod/lti/services.php/31/lineitems/12/lineitem?type_id=3" }
  • scope : les scopes que la plateforme est prête à accorder à l’outil dans ce contexte ;
  • lineitems : l’URL du conteneur de lignes de notes du contexte (pour lister/créer) ;
  • lineitem : présent uniquement si le launch est déjà couplé à une ligne de note précise (activité notée, ou lineItem créé via Deep Linking) — c’est le cas idéal : vous postez vos scores directement dessus.

Les quatre scopes AGS, avec leur signification exacte :

Scope (URI exacte)Donne le droit de
https://purl.imsglobal.org/spec/lti-ags/scope/lineitemGérer les lignes de notes : GET/POST sur le conteneur, GET/PUT/DELETE sur une ligne
https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonlyLire les lignes de notes uniquement
https://purl.imsglobal.org/spec/lti-ags/scope/result.readonlyLire les résultats (notes courantes des étudiants) — lecture seule par conception, il n’existe pas de scope d’écriture de results
https://purl.imsglobal.org/spec/lti-ags/scope/scorePublier des scores (POST sur {lineitem}/scores)

Persistez ces URLs au moment du launch (associées au contexte/resource link) : c’est ce qui vous permettra de pousser une note plus tard, de façon asynchrone, quand l’étudiant aura fini son quiz — même si sa session de launch a expiré depuis longtemps.

Lister les lignes de notes

# GET du conteneur de lineitems curl -s "https://moodle.example.com/mod/lti/services.php/31/lineitems?type_id=3" \ -H "Authorization: Bearer e5c3a1f09b8d7c6e5f4a3b2c1d0e9f8a7b6c5d4e" \ -H "Accept: application/vnd.ims.lis.v2.lineitemcontainer+json"
// Réponse 200 — Content-Type: application/vnd.ims.lis.v2.lineitemcontainer+json [ { "id": "https://moodle.example.com/mod/lti/services.php/31/lineitems/12/lineitem?type_id=3", "label": "Quiz de mi-semestre — Réseaux", "scoreMaximum": 20, "resourceId": "qz_58c21f", "resourceLinkId": "17", "tag": "midterm", "startDateTime": "2026-09-01T08:00:00+00:00", "endDateTime": "2026-09-15T23:59:00+00:00" }, { "id": "https://moodle.example.com/mod/lti/services.php/31/lineitems/13/lineitem?type_id=3", "label": "Quiz bonus — Subnetting", "scoreMaximum": 5, "resourceId": "qz_91ab3d", "resourceLinkId": "19", "tag": "bonus" } ]

L’id d’un lineitem est son URL (style REST/HATEOAS) : c’est elle que vous utilisez pour les opérations suivantes. Le conteneur supporte les filtres ?resource_link_id=, ?resource_id=, ?tag=, ?limit= et la pagination par header Link (même mécanique que NRPS, voir 7.3).

Créer une ligne de note

# POST d'un nouveau lineitem dans le conteneur curl -s -X POST "https://moodle.example.com/mod/lti/services.php/31/lineitems?type_id=3" \ -H "Authorization: Bearer e5c3a1f09b8d7c6e5f4a3b2c1d0e9f8a7b6c5d4e" \ -H "Content-Type: application/vnd.ims.lis.v2.lineitem+json" \ -d '{ "label": "Quiz surprise — Couche transport", "scoreMaximum": 10, "resourceId": "qz_c4e871", "tag": "surprise" }'

Réponse 201 Created avec le lineitem complet (dont son id-URL) dans le body. La colonne apparaît immédiatement dans le carnet de notes Moodle du cours, dans la catégorie dédiée à l’outil.

Publier un score

L’opération reine. On POST sur l’URL du lineitem suffixée par /scores :

# POST d'un score — l'étudiant sub 9d3c7f2e-... a eu 16,5/20 curl -s -X POST "https://moodle.example.com/mod/lti/services.php/31/lineitems/12/lineitem/scores?type_id=3" \ -H "Authorization: Bearer e5c3a1f09b8d7c6e5f4a3b2c1d0e9f8a7b6c5d4e" \ -H "Content-Type: application/vnd.ims.lis.v1.score+json" \ -d '{ "userId": "9d3c7f2e-51a4-4b8e-a2f1-6c0d8e9b4a17", "scoreGiven": 16.5, "scoreMaximum": 20, "comment": "Bonne maîtrise du subnetting, revoir TCP slow start.", "activityProgress": "Completed", "gradingProgress": "FullyGraded", "timestamp": "2026-09-12T14:32:05.000Z" }'

Champ par champ (media type application/vnd.ims.lis.v1.score+json — notez le v1, contrairement aux lineitems en v2) :

  • userId : le sub LTI de l’étudiant (PAS un email, PAS un id Moodle brut) ;
  • scoreGiven / scoreMaximum : le score et son barème au moment de la tentative. scoreMaximum accompagne obligatoirement scoreGiven ; Moodle convertira proportionnellement vers le scoreMaximum du lineitem si les barèmes diffèrent ;
  • activityProgress : où en est l’étudiant dans l’activité — Initialized, Started, InProgress, Submitted, Completed ;
  • gradingProgress : où en est la notation — FullyGraded, Pending, PendingManual, Failed, NotReady. Moodle n’inscrit la note au carnet que pour FullyGraded ;
  • timestamp : ISO 8601 avec timezone. Sert d’ordre logique : la plateforme doit rejeter (409) un score dont le timestamp est antérieur ou égal au dernier score enregistré pour ce couple (lineitem, userId). C’est votre protection contre les écritures désordonnées (retries, workers concurrents) — générez le timestamp au moment de l’événement métier, pas au moment de l’envoi.

Un score sans scoreGiven mais avec activityProgress: "Submitted" et gradingProgress: "PendingManual" est parfaitement légal : il signale « copie rendue, correction manuelle en attente ».

Lire les résultats

# GET des résultats du lineitem curl -s "https://moodle.example.com/mod/lti/services.php/31/lineitems/12/lineitem/results?type_id=3" \ -H "Authorization: Bearer e5c3a1f09b8d7c6e5f4a3b2c1d0e9f8a7b6c5d4e" \ -H "Accept: application/vnd.ims.lis.v2.resultcontainer+json"
// Réponse 200 — application/vnd.ims.lis.v2.resultcontainer+json [ { "id": "https://moodle.example.com/mod/lti/services.php/31/lineitems/12/lineitem/results?type_id=3&user_id=9d3c7f2e-51a4-4b8e-a2f1-6c0d8e9b4a17", "scoreOf": "https://moodle.example.com/mod/lti/services.php/31/lineitems/12/lineitem?type_id=3", "userId": "9d3c7f2e-51a4-4b8e-a2f1-6c0d8e9b4a17", "resultScore": 16.5, "resultMaximum": 20, "comment": "Bonne maîtrise du subnetting, revoir TCP slow start." } ]

Différence conceptuelle importante : un score est un événement que l’outil pousse (« voici le résultat de cette tentative ») ; un result est l’état courant de la note dans le carnet, que l’outil lit. Le result peut différer du dernier score poussé : l’enseignant a pu surcharger la note à la main dans Moodle. Filtre disponible : ?user_id=<sub>.

7.3 NRPS — Names and Role Provisioning Services : la liste des inscrits

Le claim et le scope

// Claim NRPS dans l'id_token de launch "https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice": { "context_memberships_url": "https://moodle.example.com/mod/lti/services.php/CourseSection/31/bindings/3/memberships", "service_versions": ["1.0", "2.0"] }

Scope unique, en lecture seule par conception :

https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly

L’appel

# GET des membres du contexte curl -s "https://moodle.example.com/mod/lti/services.php/CourseSection/31/bindings/3/memberships" \ -H "Authorization: Bearer e5c3a1f09b8d7c6e5f4a3b2c1d0e9f8a7b6c5d4e" \ -H "Accept: application/vnd.ims.lti-nrps.v2.membershipcontainer+json"
// Réponse 200 — application/vnd.ims.lti-nrps.v2.membershipcontainer+json { "id": "https://moodle.example.com/mod/lti/services.php/CourseSection/31/bindings/3/memberships", "context": { "id": "31", "label": "RES-101", "title": "Introduction aux réseaux informatiques" }, "members": [ { "status": "Active", "user_id": "9d3c7f2e-51a4-4b8e-a2f1-6c0d8e9b4a17", "name": "Alex Lemia", "given_name": "Alex", "family_name": "Lemia", "email": "alexlemia13@gmail.com", "picture": "https://moodle.example.com/pluginfile.php/55/user/icon/boost/f1?rev=1204", "lis_person_sourcedid": "etu-2026-00842", "roles": [ "http://purl.imsglobal.org/vocab/lis/v2/membership#Learner" ] }, { "status": "Active", "user_id": "1a2b3c4d-88ef-4a01-9c55-7e6f5d4c3b2a", "name": "Marie Duval", "given_name": "Marie", "family_name": "Duval", "email": "m.duval@univ-exemple.fr", "lis_person_sourcedid": "ens-0917", "roles": [ "http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor" ] } ] }

Les user_id sont les mêmes valeurs que les sub des launches : c’est la jointure naturelle entre NRPS et vos comptes. status vaut Active, Inactive ou Deleted (précieux pour désactiver les accès des désinscrits). Les champs name/email suivent les mêmes réglages de confidentialité que le launch : si la plateforme ne partage pas les emails au launch, NRPS ne les donnera pas non plus.

La pagination se fait par header Link (RFC 8288), pas dans le body :

HTTP/1.1 200 OK Content-Type: application/vnd.ims.lti-nrps.v2.membershipcontainer+json Link: <https://moodle.example.com/mod/lti/services.php/CourseSection/31/bindings/3/memberships?limit=100&from=100>; rel="next"

Suivez rel="next" jusqu’à son absence ; un rel="differences" peut aussi apparaître (URL à rappeler plus tard pour n’obtenir que les changements depuis cet appel — la synchronisation incrémentale idéale pour un cron).

Filtres en query string :

  • ?role=Instructor — ne renvoyer que les membres ayant ce rôle (nom court ou URI complète) ;
  • ?limit=50 — taille de page souhaitée (indicative : la plateforme peut en décider autrement) ;
  • ?rlid=17 — restreindre aux membres ayant accès au resource link 17 (une activité cachée à un groupe ne « voit » pas les autres). Avec rlid, chaque membre peut porter un champ additionnel message contenant les claims (dont custom résolu) qu’aurait un launch de cette personne sur cette ressource — pratique pour pré-provisionner sans attendre le premier launch de chacun.

⚠️ Piège : ne construisez JAMAIS les URLs de service vous-même par concaténation (« tiens, le pattern c’est /mod/lti/services.php/{courseid}/lineitems »). Utilisez exclusivement les URLs reçues dans les claims (lineitems, lineitem, context_memberships_url) et dans les id des ressources. Le format interne des URLs Moodle (le ?type_id=3, le /bindings/3/) est un détail d’implémentation non contractuel qui varie selon la configuration et les versions — et d’autres plateformes (Canvas, Blackboard) ont des formats radicalement différents. Un outil qui hardcode les patterns Moodle n’est pas un outil LTI, c’est un plugin Moodle déguisé.

📚 Aller plus loin : les specs complètes des services sont LTI Assignment and Grade Services 2.0 et LTI Names and Role Provisioning Services 2.0 sur 1edtech.org/standards/lti. Côté Moodle, l’implémentation vit dans mod/lti/service/gradebookservices/ et mod/lti/service/memberships/ — lire classes/local/service/*.php y est très instructif pour comprendre les limites concrètes (par exemple quelles colonnes de carnet sont visibles par l’outil : uniquement celles qu’il a créées ou qui lui sont couplées). Le LTI Advantage Reference Implementation d’1EdTech (ltiadvantagevalidator) permet de tester votre outil contre une plateforme de certification avant de viser la certification officielle.


8. Sécurité : pourquoi ça tient, et comment on le casse

8.1 Pourquoi l’architecture est solide

Comparé à LTI 1.0/1.1 (OAuth 1.0a, secret partagé, signature HMAC des paramètres de formulaire), LTI 1.3 apporte cinq propriétés structurantes :

  1. Cryptographie asymétrique de bout en bout. Aucun secret partagé n’existe : chaque acteur ne détient que SA clé privée, et publie sa clé publique. Compromettre la base de données de l’un ne donne pas le droit de se faire passer pour l’autre. En LTI 1.1, le secret partagé stocké côté plateforme ET côté outil était un single point of failure double.
  2. Courte durée de vie. id_token de launch : ~1 minute. client_assertion : ~1 minute. access_token : ~1 heure. Un artefact volé a une fenêtre d’exploitation minuscule, et le nonce ferme même cette fenêtre pour les launches.
  3. Audience restreinte. Chaque JWT nomme son destinataire (aud) : un id_token émis pour QuizLab est cryptographiquement inutilisable auprès d’un autre outil, même signé par le même Moodle. Idem pour les assertions (aud = token endpoint).
  4. Pas de secret en transit. Le seul matériel sensible qui circule est l’access token Bearer, sur TLS, avec 1 h de vie et des scopes minimaux. Tout le reste est signé, vérifiable publiquement, non falsifiable.
  5. Le navigateur ne voit que des choses signées ou aléatoires. L’utilisateur (ou une extension malveillante) peut lire l’id_token qui transite par son navigateur, mais pas le modifier (signature) ni le rejouer (nonce) ni le détourner (aud, state).

8.2 Le bestiaire des erreurs d’implémentation

L’architecture est saine ; les implémentations, beaucoup moins. Voici les erreurs réellement observées dans la nature, par ordre de gravité.

Ne pas vérifier la signature du tout. Ça paraît impensable, mais des outils « décodent » le JWT (JSON.parse(atob(payload))) sans jamais appeler verify. Conséquence : n’importe qui peut forger un launch avec le rôle Instructor et le sub de son choix. Test simple : modifiez un caractère du payload d’un launch légitime et rejouez — si ça passe, c’est cassé.

Accepter alg: "none" ou HS256. Deux variantes du même désastre. alg: none = JWT non signé accepté tel quel (les vieilles libs le permettaient par défaut). HS256 = l’attaquant signe le JWT en HMAC en utilisant… la clé publique RSA de la plateforme comme secret HMAC — clé publique par définition connue de tous ; les libs qui prennent un paramètre key polymorphe se font piéger. Parade absolue : allowlist ["RS256"] passée explicitement à la vérification, jamais l’algorithme lu dans le header.

Ignorer state ou nonce. Sans state : CSRF de launch — un attaquant force le navigateur d’une victime à consommer un launch de son cru (login CSRF : la victime travaille sans le savoir dans le compte outil de l’attaquant, ou inversement selon le montage). Sans nonce (ou avec un store de nonces qui ne les brûle pas) : tout id_token intercepté est rejouable pendant sa durée de vie, et « intercepté » inclut les logs d’accès où un dev a fait transiter le token en GET.

Ne pas pinner le couple issuer ↔ client_id ↔ deployment_id. Votre outil sert dix plateformes ? Alors vérifier « l’iss est l’un de mes issuers connus » et « l’aud est l’un de mes client_ids » séparément est une faille : le Moodle A (issuer légitime) peut émettre un JWT avec le client_id de votre enregistrement chez le Moodle B et polluer les données du tenant B. Validez le tuple : ce client_id-là est-il bien celui enregistré pour cet issuer-là, et ce deployment_id est-il déclaré pour ce couple ? Même logique côté sessions : namespacez sub par issuer (deux plateformes peuvent émettre le même sub !).

Cache JWKS trop naïf — dans les deux sens. Sans re-fetch sur kid inconnu : la première rotation de clés de Moodle casse tous les launches jusqu’au redémarrage de votre process. Avec re-fetch systématique non limité : déni de service par JWT bidons (votre serveur martèle certs.php). La bonne mécanique : cache avec TTL (quelques heures), re-fetch immédiat si kid absent du cache, mais au plus une fois par minute et par issuer, échec définitif sinon.

Clock skew mal calibré. Tolérance zéro : des launches échouent aléatoirement parce que l’horloge du serveur Moodle dérive de 20 secondes (les tokens Moodle vivent 60 s, ça ne pardonne pas) — bug intermittent infernal à diagnostiquer. Tolérance de 10 minutes : vous décuplez la fenêtre de rejeu et rendez exp décoratif. La norme de fait : 60 secondes, et du NTP partout.

redirect_uri non validé côté plateforme. Faille côté Moodle plutôt que côté outil, mais elle vous concerne : si la plateforme redirigeait le form_post vers un redirect_uri non pré-enregistré, un attaquant pourrait exfiltrer des id_token valides en initiant des logins avec son propre redirect. Moodle valide par comparaison exacte avec les URIs enregistrées — raison pour laquelle votre launch casse quand vous changez de domaine de préproduction sans mettre à jour l’enregistrement. Ne demandez jamais à un admin d’enregistrer un wildcard ou une URI « de debug » du type webhook.site.

Confondre le launch et la session. Deux symétriques : (a) exiger un id_token valide à chaque requête (l’utilisateur est déconnecté au bout de 60 s, ou pire, le dev « rallonge » la validité des id_token) ; (b) créer une session de 30 jours depuis un launch et continuer à honorer le rôle Instructor d’un utilisateur désinscrit depuis. Le launch est un événement d’authentification ; la session est un objet applicatif à vous, avec sa propre durée raisonnable, ses propres renouvellements, et une re-validation des droits pour les actions sensibles.

XSS via les claims affichés. resource_link.title, context.title, name, les custom, le comment d’un result : toutes ces valeurs sont saisies par des humains côté plateforme et arrivent chez vous non assainies. Un titre d’activité <img src=x onerror=alert(document.cookie)> est un launch parfaitement valide cryptographiquement. Échappez à l’affichage comme n’importe quelle donnée non fiable. React vous protège par défaut dans le JSX — sauf si vous faites du dangerouslySetInnerHTML sur une description « riche », ce qui arrive plus souvent qu’on ne l’avoue ; passez alors par DOMPurify.

⚠️ Piège : la validation d’iat mérite une mention : certaines plateformes (pas Moodle) émettent des JWT avec un iat légèrement futur à cause de leur propre dérive d’horloge. Rejeter iat > now strictement crée des échecs fantômes ; c’est bien iat > now + skew qu’il faut rejeter. Et ne recalculez pas une expiration maison à partir d’iat (« iat + 5 min ») quand exp existe : exp fait foi.

📚 Aller plus loin : le document normatif de sécurité est l’IMS Security Framework 1.1 (1edtech.org — il définit le profil OIDC de LTI, le client_credentials JWT et les exigences de validation), à compléter par les Best Current Practices OAuth 2.0 de l’IETF (RFC 9700) et l’étude « Extended Security Analysis of LTI 1.3 » qui a inspiré plusieurs durcissements de la spec. Côté pratique, la suite de certification 1EdTech rejoue précisément les attaques listées ci-dessus (JWT alg=none, nonce rejoué, aud falsifié…) : passer la certification, c’est passer ce bestiaire.


Ce qu’il faut retenir

  • LTI 1.3 = deux jambes. Les messages (launches) passent par le navigateur : OIDC third-party initiated login + response_mode=form_post, payload = id_token JWT signé RS256 par la plateforme. Les services (AGS, NRPS) sont du REST serveur-à-serveur : OAuth2 client_credentials avec assertion JWT (RFC 7523), Bearer token, scopes en URIs.
  • Le flow de launch en quatre temps : (a) Moodle POST la login initiation à l’outil (iss, login_hint, lti_message_hint opaques) ; (b) l’outil redirige vers mod/lti/auth.php avec response_type=id_token, prompt=none, state + cookie et nonce + store ; (c) Moodle authentifie sur sa session existante et form_poste l’id_token ; (d) l’outil valide TOUT et crée sa propre session.
  • statenonce : state revient en paramètre à côté du JWT et se compare au cookie (anti-CSRF, lie le flux au navigateur) ; nonce voyage DANS le JWT signé et se brûle à usage unique (anti-rejeu du token).
  • L’identité d’une intégration est le triplet (issuer, client_id, deployment_id), et l’identité d’un utilisateur est (issuer, sub). Tout le reste — email, name, sourcedid — est optionnel, soumis à la confidentialité, et ne doit jamais servir de clé.
  • Les claims sont des URIs normatives sous https://purl.imsglobal.org/spec/lti/claim/... (et lti-dl, lti-ags, lti-nrps) ; les rôles sous http://purl.imsglobal.org/vocab/lis/v2/.... Zéro tolérance sur l’orthographe, et seuls les rôles de contexte (membership#Instructor, membership#Learner) fondent les autorisations.
  • Deux paires de clés RSA : Moodle signe les id_token (vérifiés via certs.php) ; l’outil signe les client_assertion et les LtiDeepLinkingResponse (vérifiés via le JWKS de l’outil). Chacun signe ce qu’il émet, chacun vérifie avec le JWKS de l’autre.
  • Deep Linking est un aller-retour : LtiDeepLinkingRequest (avec deep_linking_settings : deep_link_return_url, accept_types, data opaque) → picker de l’outil → JWT LtiDeepLinkingResponse signé par l’outil avec content_items (dont custom et lineItem), posté au return URL.
  • AGS : le claim endpoint livre lineitems/lineitem et les scopes ; on pousse des scores en application/vnd.ims.lis.v1.score+json (userId = sub, scoreGiven/scoreMaximum, activityProgress, gradingProgress, timestamp ordonnançant les écritures) ; on lit des results (état du carnet, l’enseignant peut avoir surchargé). NRPS : context_memberships_url, scope contextmembership.readonly, pagination par header Link, filtres role/rlid.
  • La sécurité tient si et seulement si la checklist est complète : signature RS256 (allowlist d’algorithmes !), iss/aud/azp pinnés, exp/iat avec ~60 s de skew, nonce à usage unique, state == cookie, deployment_id connu, JWKS caché-mais-rafraîchi, claims affichés échappés, et launch ≠ session.

Vous savez maintenant exactement ce qui circule sur le fil, dans les deux sens. Dans le prochain chapitre, 03-moodle-plateforme.md, nous changeons de casquette : Moodle ne sera plus une boîte noire qui émet des JWT, mais VOTRE plateforme à configurer — enregistrement manuel et enregistrement dynamique des outils, réglages de confidentialité et de placement, gestion des clés et des déploiements, et le débogage des launches côté Moodle quand (inévitablement) quelque chose refuse de se lancer.