Chapitre 10.1 — LTI Advantage : vue d’ensemble
⏱️ TL;DR — LTI Advantage = LTI 1.3 Core (le lancement de la Partie 9) plus trois services : Deep Linking, AGS (notes) et NRPS (roster). Les endpoints de ces services n’arrivent pas par magie : ils sont posés dans le
id_tokendu lancement, sous forme de claims (.../lti-ags/claim/endpoint,.../lti-nrps/claim/namesroleservice). Pour appeler un service, ton outil ne réutilise pas l’id_token: il obtient un jeton d’accès OAuth2 auprès du token endpoint de la plateforme, via le grantclient_credentialset un JWTclient_assertionsigné avec sa propre clé privée. Le jeton renvoyé est un bearer que tu présentes aux endpoints de service. Lescopedemandé borne ce que ce jeton a le droit de faire.
🎯 Objectifs
- Situer LTI Advantage : Core + Deep Linking + AGS + NRPS.
- Repérer d’où viennent les endpoints de service (les claims du
id_token). - Obtenir un jeton d’accès via
client_credentials+client_assertion. - Comprendre le rôle du
scopeet demander le minimum nécessaire.
Core, plus trois services
La Partie 9 t’a appris le lancement : l’élève clique, ton outil valide le id_token, affiche son contenu. C’est LTI 1.3 Core. Utile, mais unidirectionnel : l’information va de la plateforme vers ton outil, et s’arrête là.
LTI Advantage ajoute la conversation retour : ton outil peut, à son tour, appeler la plateforme. Trois services, trois usages :
| Service | À quoi ça sert | Sens du flux |
|---|---|---|
| Deep Linking | L’enseignant choisit quel contenu de ton outil placer dans le cours (au lieu d’une URL en dur). | Outil ⟶ Plateforme (renvoie un choix) |
| AGS (Assignment and Grade Services) | Ton outil renvoie des notes dans le carnet (colonnes, scores, résultats). | Outil ⟶ Plateforme (écrit/lit des notes) |
| NRPS (Names and Role Provisioning Services) | Ton outil lit la liste des membres du cours et leurs rôles. | Outil ⟵ Plateforme (lit le roster) |
📚 La spec — « LTI Advantage » n’est pas une version : c’est un regroupement de LTI 1.3 Core + les trois services, chacun spécifié à part par 1EdTech (Deep Linking, Assignment and Grade Services, Names and Role Provisioning Services). Un produit peut n’en implémenter qu’un ; la certification couvre chaque service séparément (chapitre 10.5). Détails sur
1edtech.org.
D’où viennent les endpoints de service
Tu ne « devines » jamais l’URL d’un service. La plateforme te la donne à chaque lancement, dans un claim du id_token (celui que tu as validé au chapitre 9.4). Deux claims de service à connaître :
{
"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/result.readonly",
"https://purl.imsglobal.org/spec/lti-ags/scope/score"
],
"lineitems": "https://moodle.ecole.fr/mod/lti/services.php/2/lineitems",
"lineitem": "https://moodle.ecole.fr/mod/lti/services.php/2/lineitems/5/lineitem"
},
"https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice": {
"context_memberships_url": "https://moodle.ecole.fr/mod/lti/services.php/CtxMemberships.php/2",
"service_versions": ["2.0"]
}
}- Le claim AGS (
.../lti-ags/claim/endpoint) portescope(les scopes que cette plateforme t’autorise),lineitems(l’URL du conteneur de colonnes du cours) etlineitem(l’URL de la colonne liée à cette activité, si elle existe déjà). On l’exploite au chapitre 10.3. - Le claim NRPS (
.../lti-nrps/claim/namesroleservice) portecontext_memberships_url(l’URL du roster de ce cours) etservice_versions. On l’exploite au chapitre 10.4.
💡 Réflexe — Persiste ces URLs au moment du lancement, indexées par
sub+iss+context.id. Un renvoi de note peut arriver plus tard (l’élève finit l’exercice à 22 h, ton job asynchrone poste le score à 22 h 05) : tu n’auras plus l’id_tokensous la main. Range l’endpoint AGS avec le contexte, pas dans la requête HTTP en cours.
⚠️ Piège — Croire que l’
id_tokendu lancement est le jeton pour appeler les services. Non. L’id_tokenprouve qui est l’élève et livre les URLs de service. Pour appeler un service, il te faut un autre jeton — unaccess_tokenOAuth2 que tu vas chercher toi-même. C’est l’objet de la section suivante.
Obtenir un jeton d’accès
Appeler AGS ou NRPS, c’est appeler une API REST protégée. Il te faut donc un access_token OAuth2 (de type bearer). Tu l’obtiens auprès du token endpoint de la plateforme (reçu à l’enregistrement, chapitre 9.5) avec le grant client_credentials — mais pas avec un secret partagé : avec un JWT client_assertion que ton outil signe avec sa clé privée.
Voici la requête complète :
POST /mod/lti/token.php HTTP/1.1
Host: moodle.ecole.fr
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=eyJhbGciOiJSUzI1NiIsImtpZCI6InN0dWRpby0yMDI2In0.eyJpc3M...
&scope=https://purl.imsglobal.org/spec/lti-ags/scope/score%20https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonlyChaque paramètre a un rôle précis :
grant_type=client_credentials: « je m’authentifie en tant qu’application », pas au nom d’un utilisateur.client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer: « ma preuve d’identité est un JWT ».client_assertion: le JWT signé par ta clé privée. C’est lui qui prouve que tu es bien ton outil. La plateforme le vérifie avec tonjwks_uri(celui que tu lui as donné à l’enregistrement).scope: la liste des scopes souhaités, séparés par des espaces (encodés%20dans le corps). Tu ne demandes que ce dont tu as besoin.
Le client_assertion, décodé, est un JWT très court :
{
"iss": "formacampus-studio-client-id",
"sub": "formacampus-studio-client-id",
"aud": "https://moodle.ecole.fr/mod/lti/token.php",
"iat": 1893455940,
"exp": 1893456240,
"jti": "req-a1b2c3-unique"
}issetsubvalent tonclient_id: l’émetteur du jeton, c’est toi, l’outil.aud: le token endpoint visé (l’audience de cette assertion).iat/exp: émis maintenant, très courte durée de vie (quelques minutes).jti: un identifiant unique par requête (anti-rejeu).
Le header du JWT porte l’alg (RS256) et le kid de ta clé — pour que la plateforme choisisse la bonne clé publique dans ton JWKS.
La plateforme répond avec le jeton :
HTTP/1.1 200 OK
Content-Type: application/json
{
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "https://purl.imsglobal.org/spec/lti-ags/scope/score"
}Note deux choses : token_type est Bearer (tu le mettras dans l’en-tête Authorization), et le scope renvoyé peut être plus restreint que celui demandé (la plateforme ne t’accorde que ce à quoi tu as droit). Ensuite, tu appelles le service :
POST /mod/lti/services.php/2/lineitems/5/scores HTTP/1.1
Host: moodle.ecole.fr
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...
Content-Type: application/vnd.ims.lis.v1.score+json
{ "...": "objet Score, voir chapitre 10.3" }💡 Réflexe — Mets en cache l’
access_tokenjusqu’à sonexpires_in(moins une marge de sécurité) et réutilise-le pour toutes tes requêtes vers cette plateforme. Redemander un jeton à chaque appel HTTP, c’est doubler les allers-retours et marteler le token endpoint pour rien. Un jeton par plateforme (voire par jeu de scopes), rafraîchi à l’expiration.
Les scopes
Un scope est une IRI qui nomme une permission. Le jeton que tu obtiens ne peut faire que ce que ses scopes autorisent. Ceux de LTI Advantage :
| Scope (IRI) | Ce qu’il autorise | Service |
|---|---|---|
https://purl.imsglobal.org/spec/lti-ags/scope/lineitem | Gérer les colonnes du carnet (créer, lire, modifier). | AGS |
https://purl.imsglobal.org/spec/lti-ags/scope/score | Publier des scores dans une colonne. | AGS |
https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly | Lire les résultats d’une colonne. | AGS |
https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly | Lire le roster du cours. | NRPS |
Deep Linking, lui, ne passe pas par un scope de service : il fonctionne par lancement dédié (LtiDeepLinkingRequest) et réponse signée, sans jeton d’accès (chapitre 10.2).
⚠️ Piège — Demander tous les scopes « au cas où ». Si tu ne fais que poster des notes, ne demande que
.../lti-ags/scope/score. Un jeton sur-scopé est une surface d’attaque inutile, et certaines plateformes refusent un scope non prévu à ta configuration — la requête token échoue alors avec une erreurinvalid_scopedéroutante. Principe du moindre privilège (on y revient en 10.5).
Le flux, en un diagramme
Retiens la séquence : forger l’assertion → échanger contre un access_token → appeler le service avec le bearer. Les trois services partagent exactement ce préambule ; seule change l’URL appelée et le scope demandé.
🔌 Côté intégration — Ce mécanisme de jeton est le même pour AGS et NRPS. Quand tu industrialises ton outil, factorise-le : une fonction
getServiceToken(platform, scopes)qui forge l’assertion, appelle le token endpoint, met en cache et renvoie le bearer. Tes implémentations AGS et NRPS ne font ensuite que consommer ce bearer. C’est ce niveau de propreté que vérifient les tests de certification.
🧭 Sur FormaCampus — Le studio expose déjà un
jwks_uri(donné aux écoles à l’enregistrement). Pour LTI Advantage, l’équipe ajoute un moduleservices/token.tsqui, à partir de l’issd’une école, forge unclient_assertion(signé par la clé privée du studio), appelle le token endpoint de cette école et cache leaccess_tokenpar(iss, scope). Les modules AGS (renvoi de note) et NRPS (roster) s’appuient dessus. Premier jalon franchi : le studio sait parler à la plateforme, plus seulement l’écouter.
📚 La spec — L’obtention du jeton suit OAuth 2.0 Client Credentials (RFC 6749) avec l’authentification client par JWT Bearer (RFC 7523) :
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer. 1EdTech en fixe le profil dans LTI 1.3 Security Framework et les IRIs de scope dans chaque spec de service. Les types de média (application/vnd.ims.lis.v1.score+json, etc.) sont normés — vois1edtech.org.
✏️ Exercices
Exercice 1 — Retrouve l’endpoint. Ton outil reçoit un lancement validé. Tu veux poster une note. Dans quel claim du id_token trouves-tu l’URL où poster, et quel jeton utilises-tu pour appeler cette URL ?
✅ Solution
L’URL est dans le claim https://purl.imsglobal.org/spec/lti-ags/claim/endpoint, champ lineitem (la colonne liée à l’activité) — on postera sur {lineitem}/scores. Le jeton n’est pas l’id_token : c’est un access_token OAuth2 obtenu au token endpoint via grant_type=client_credentials + un client_assertion (JWT signé par ta clé privée), avec le scope .../lti-ags/scope/score. On présente ce bearer dans l’en-tête Authorization.
Exercice 2 — Corrige la requête. Un dev poste ceci pour obtenir un jeton et reçoit une erreur invalid_scope :
POST /mod/lti/token.php HTTP/1.1
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=eyJ...
&scope=https://purl.imsglobal.org/spec/lti-ags/scope/score,https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonlyQu’est-ce qui cloche ?
✅ Solution
Les scopes sont séparés par une virgule. Le paramètre scope OAuth2 attend des scopes séparés par des espaces (encodés %20 dans un corps x-www-form-urlencoded), pas par des virgules. La plateforme lit alors un « scope » unique invalide .../score,.../contextmembership.readonly et répond invalid_scope. Corriger : remplacer la virgule par %20. (Vérifier aussi que ces scopes sont bien autorisés pour ce client_id dans la config de la plateforme.)
🧠 Quiz de révision
1. Que regroupe « LTI Advantage » ?
LTI 1.3 Core (le lancement) plus trois services : Deep Linking (choix de contenu), AGS (notes) et NRPS (roster). Ce n’est pas une version, c’est un ensemble de specs certifiables séparément.
2. Où trouve-t-on les URLs des services AGS et NRPS ?
Dans les claims du id_token du lancement : .../lti-ags/claim/endpoint (champs lineitems, lineitem, scope) pour AGS, et .../lti-nrps/claim/namesroleservice (champ context_memberships_url) pour NRPS.
3. Comment un outil obtient-il un jeton pour appeler un service ?
Il POST au token endpoint de la plateforme avec grant_type=client_credentials, client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer et un client_assertion = JWT signé par sa clé privée, plus le scope voulu. Il reçoit un access_token de type Bearer.
4. Peut-on réutiliser l’id_token du lancement pour appeler AGS ?
id_token du lancement pour appeler AGS ?Non. L’id_token identifie l’utilisateur et livre les URLs de service, mais n’autorise pas les appels d’API. Il faut un access_token OAuth2 distinct, obtenu séparément.
5. À quoi sert le scope, et quelle règle appliquer ?
scope, et quelle règle appliquer ?Le scope borne ce que le jeton peut faire (poster un score, lire le roster…). Règle : demander le minimum nécessaire (moindre privilège). La plateforme peut accorder un scope plus restreint que celui demandé, et refuse (invalid_scope) un scope non prévu à ta config.
Chapitre suivant : Deep Linking — comment l’enseignant choisit, dans ton outil, le contenu à placer dans son cours.