Skip to Content
MoodlePartie 10 — LTI (Learning Tools Interoperability)Construire un outil LTI 1.3 complet en Next.js

Chapitre 10.5 — Construire un outil LTI 1.3 complet en Next.js

Les chapitres précédents ont posé la théorie : le modèle de confiance de LTI 1.3, le flow OIDC third-party initiated login, les services LTI Advantage (AGS, NRPS, Deep Linking). Il est temps de passer aux travaux pratiques. Dans ce chapitre — le plus long et le plus important de cette partie — nous allons construire QuizLab, un outil LTI 1.3 complet en Next.js 15 (App Router), et le brancher sur un Moodle 5.2. À la fin, vous aurez :

  • un launch SSO fonctionnel : l’utilisateur clique sur une activité dans Moodle et arrive authentifié sur une page React qui affiche son identité, son cours et son rôle ;
  • le Deep Linking : l’enseignant choisit un quiz dans une interface de sélection, et Moodle crée l’activité correspondante ;
  • AGS : le score du quiz remonte automatiquement dans le carnet de notes Moodle ;
  • NRPS : l’enseignant consulte la liste des inscrits du cours depuis l’outil.

Et surtout : vous comprendrez chaque octet qui transite, parce que nous allons tout écrire à la main avant de regarder ce qu’un framework comme ltijs aurait fait à notre place.

Mini-sommaire

  1. Objectif et architecture
  2. Mise en place du projet
  3. Configuration et registre des plateformes
  4. Exposer la clé publique : /api/lti/jwks
  5. L’initiation OIDC : /api/lti/login
  6. Le launch : /api/lti/launch
  7. La page React /lti/app
  8. Enregistrer l’outil dans Moodle 5.2
  9. Le problème des cookies en iframe
  10. Deep Linking : proposer du contenu à Moodle
  11. AGS : écrire dans le carnet de notes
  12. NRPS : lire le roster du cours
  13. Checklist de mise en production
  14. Refaire la même chose avec ltijs
  15. Ce qu’il faut retenir

1. Objectif et architecture

1.1 Ce que nous construisons

QuizLab est un outil de quiz minimaliste mais structurellement complet : tout ce qu’un vrai produit EdTech doit implémenter pour s’intégrer à un LMS y est présent. Les conventions utilisées dans tout le chapitre :

ÉlémentValeur
Outil (prod)https://tool.example.com
Outil (dev)tunnel HTTPS (ngrok / cloudflared) vers localhost:3000
Plateforme Moodlehttps://moodle.example.com
Endpoint d’autorisation Moodlehttps://moodle.example.com/mod/lti/auth.php
Endpoint de token Moodlehttps://moodle.example.com/mod/lti/token.php
JWKS Moodlehttps://moodle.example.com/mod/lti/certs.php
client_ida1b2c3d4e5f6g7h
deployment_id1

1.2 Pourquoi sans framework LTI (d’abord) ?

Il existe des bibliothèques qui font tout cela pour vous — nous verrons ltijs en fin de chapitre. Mais LTI 1.3 est un protocole de sécurité : chaque paramètre du flow OIDC, chaque claim du JWT, chaque cookie a une raison d’être, et les bugs d’intégration LTI sont presque toujours des bugs de compréhension du protocole (state perdu, nonce rejoué, audience mal vérifiée, cookie bloqué en iframe). Un développeur qui a écrit une fois le flow à la main saura déboguer n’importe quelle intégration LTI — y compris celles faites avec un framework. Notre stack :

  • Next.js 15, App Router : les endpoints LTI sont des route handlers (app/api/.../route.ts), le contenu est en React Server Components ;
  • TypeScript strict : les claims LTI sont typés, y compris leurs clés en URI https://purl.imsglobal.org/... ;
  • jose : la bibliothèque JWT/JWKS de référence en JavaScript (zéro dépendance, WebCrypto, fonctionne en Node comme en Edge runtime). Nous utiliserons createRemoteJWKSet, jwtVerify, SignJWT, generateKeyPair, exportJWK, exportPKCS8, importPKCS8, calculateJwkThumbprint ;
  • cookies signés (JWT HS256) pour la corrélation du flow OIDC et la session applicative — pas de base de données nécessaire pour ce tutoriel.

💡 Pour un dev React : si vous avez déjà utilisé NextAuth/Auth.js, le launch LTI va vous sembler familier : c’est un flow OpenID Connect… mais inversé dans votre tête. Ici, votre application est le client OIDC et Moodle est le fournisseur d’identité. Vous n’avez ni page de login, ni table users, ni mot de passe : l’identité arrive de Moodle, signée, à chaque lancement. C’est du SSO pur.

1.3 Architecture des routes

┌───────────────────────────────────┐ │ Moodle 5.2 (plateforme) │ │ https://moodle.example.com │ │ │ │ /mod/lti/auth.php auth OIDC │ │ /mod/lti/token.php access token │ │ /mod/lti/certs.php JWKS Moodle │ └────────┬─────────────────▲─────────┘ lancements │ │ appels de service (navigateur) ▼ │ (serveur → serveur) ┌───────────────────────────────────────────────────────────────────┐ │ QuizLab — https://tool.example.com (Next.js 15, App Router) │ │ │ │ ROUTES API (le protocole) │ │ app/api/lti/login/route.ts initiation OIDC (GET + POST) │ │ app/api/lti/launch/route.ts réception id_token (POST) │ │ app/api/lti/jwks/route.ts clé publique de l'outil (GET) │ │ app/api/lti/deep-link/route.ts réponse Deep Linking (POST) │ │ app/api/quiz/submit/route.ts poste un score via AGS (POST) │ │ │ │ PAGES (le produit) │ │ app/lti/app/page.tsx le quiz (contenu principal) │ │ app/lti/deep-link/page.tsx sélection de contenu (enseignant) │ │ app/lti/roster/page.tsx participants du cours (NRPS) │ │ app/lti/launch/page.tsx secours si cookies bloqués │ │ │ │ MODULES PARTAGÉS │ │ lib/lti/config.ts config de l'outil (env, clés) │ │ lib/lti/registry.ts registre des plateformes │ │ lib/lti/keys.ts clé privée RSA (importPKCS8) │ │ lib/lti/types.ts claims LTI typés │ │ lib/lti/session.ts state + session (JWT HS256) │ │ lib/lti/nonce.ts anti-rejeu des nonces │ │ lib/lti/roles.ts helpers de rôles LIS │ │ lib/lti/ags.ts Assignment and Grade Services │ │ lib/lti/nrps.ts Names and Role Provisioning │ │ lib/quizzes.ts contenu factice │ │ scripts/generate-keys.ts génération de la paire RSA │ └───────────────────────────────────────────────────────────────────┘

Trois natures de trafic cohabitent, et il est essentiel de les distinguer dès maintenant :

  1. Le trafic navigateur du flow de lancement (login → auth Moodle → launch) : des redirections et des POST de formulaires qui traversent l’iframe. C’est là que vivent les problèmes de cookies.
  2. Le trafic navigateur applicatif : une fois la session posée, votre app React fonctionne normalement (fetch vers vos propres routes API).
  3. Le trafic serveur → serveur (AGS, NRPS) : votre backend appelle Moodle avec un access token OAuth 2.0 obtenu par client credentials + assertion JWT. Le navigateur n’y participe jamais.

📚 Aller plus loin : les spécifications de référence sont LTI 1.3 Core , IMS Security Framework  (le flow OIDC et les tokens), AGS 2.0 , NRPS 2.0  et Deep Linking 2.0 . Gardez-les ouvertes : ce chapitre y renvoie constamment.


2. Mise en place du projet

2.1 Créer le projet et installer les dépendances

npx create-next-app@latest quizlab # Réponses aux questions : # TypeScript ................. Yes # ESLint ..................... Yes # Tailwind CSS ............... No (hors sujet ici) # src/ directory ............. No # App Router ................. Yes # Import alias (@/*) ......... Yes cd quizlab npm install jose npm install --save-dev tsx

jose est notre unique dépendance runtime supplémentaire. tsx sert à exécuter les scripts TypeScript (génération de clés, debug).

2.2 Le tunnel HTTPS en développement

Moodle exige HTTPS pour un outil LTI 1.3 (les redirect_uri en http:// sont refusées, sauf localhost dans certaines configs de dev), et de toute façon les cookies Secure; SameSite=None — obligatoires en iframe, on y reviendra longuement en section 9 — ne sont acceptés par les navigateurs qu’en HTTPS. Votre localhost:3000 doit donc être exposé derrière un tunnel :

# Option A : ngrok (domaine stable gratuit après création de compte) ngrok http 3000 # → https://quizlab.eu.ngrok.io (exemple) # Option B : cloudflared cloudflared tunnel --url http://localhost:3000 # → https://xxxx-yyyy.trycloudflare.com (URL aléatoire à chaque lancement)

L’URL du tunnel devient votre TOOL_BASE_URL de dev. Dans la suite du chapitre, nous écrivons https://tool.example.com partout : en dev, remplacez mentalement par l’URL de votre tunnel.

⚠️ Piège : si votre URL de tunnel change (cloudflared sans compte, ngrok gratuit sans domaine réservé), il faut mettre à jour à la fois .env.local et la configuration de l’outil dans Moodle (Tool URL, Initiate login URL, Redirection URI, Keyset URL). Un redirect_uri qui ne correspond plus à celui enregistré produit une erreur OIDC immédiate côté Moodle. Réservez un domaine stable dès le début, vous économiserez des heures.

2.3 Les variables d’environnement

# .env.local # --- L'outil lui-même --------------------------------------------- # URL publique de l'outil (le tunnel en dev, le domaine réel en prod) TOOL_BASE_URL=https://tool.example.com # Secret HMAC pour signer les cookies (state + session). # Générez-le : openssl rand -base64 48 SESSION_SECRET=remplacez-moi-par-48-octets-aleatoires-en-base64 # Paire de clés RSA de l'outil (générée par scripts/generate-keys.ts) TOOL_PRIVATE_KEY_PATH=./keys/private.pem TOOL_PUBLIC_JWK_PATH=./keys/public.jwk.json # En prod, préférez injecter directement le contenu : # TOOL_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" # TOOL_PUBLIC_JWK={"kty":"RSA","n":"...","e":"AQAB","kid":"...","alg":"RS256","use":"sig"} # --- La plateforme Moodle ----------------------------------------- # L'issuer est l'URL racine du site Moodle, SANS slash final LTI_ISSUER=https://moodle.example.com # Fournis par Moodle après enregistrement de l'outil (section 8) LTI_CLIENT_ID=a1b2c3d4e5f6g7h LTI_DEPLOYMENT_ID=1 # Endpoints Moodle (fixes, relatifs à l'issuer) LTI_AUTH_URL=https://moodle.example.com/mod/lti/auth.php LTI_TOKEN_URL=https://moodle.example.com/mod/lti/token.php LTI_JWKS_URL=https://moodle.example.com/mod/lti/certs.php

⚠️ Piège : l’issuer de Moodle est l’URL du site sans slash final et telle qu’elle apparaît dans $CFG->wwwroot. Si votre Moodle est servi en https://moodle.example.com/ mais que vous mettez https://moodle.example.com/moodle (ou l’inverse), la vérification issuer de jwtVerify échouera avec un message peu parlant (unexpected "iss" claim value). Copiez la valeur exacte depuis l’écran « View configuration details » de Moodle (section 8.3), qui l’affiche sous le nom Platform ID.

2.4 Générer la paire de clés RSA de l’outil

L’outil signe deux choses avec sa clé privée : les réponses Deep Linking (section 10) et les assertions client pour obtenir des access tokens (section 11). Moodle vérifie ces signatures avec la clé publique, qu’il récupère sur notre endpoint JWKS (section 4). Générons la paire :

// scripts/generate-keys.ts // // Génère la paire de clés RSA de l'outil : // keys/private.pem — clé privée PKCS#8 (à protéger !) // keys/public.jwk.json — clé publique au format JWK, avec kid/alg/use // // Usage : npx tsx scripts/generate-keys.ts import { generateKeyPair, exportJWK, exportPKCS8, calculateJwkThumbprint } from "jose"; import { mkdirSync, writeFileSync, existsSync } from "node:fs"; import path from "node:path"; async function main(): Promise<void> { const keysDir = path.resolve(process.cwd(), "keys"); const privatePath = path.join(keysDir, "private.pem"); const publicPath = path.join(keysDir, "public.jwk.json"); if (existsSync(privatePath)) { console.error(`Refus d'écraser ${privatePath} — supprimez-le d'abord si c'est voulu.`); process.exit(1); } // extractable: true est indispensable : sans lui, exportPKCS8/exportJWK // refuseront d'exporter la clé générée par WebCrypto. const { publicKey, privateKey } = await generateKeyPair("RS256", { modulusLength: 2048, extractable: true, }); // Clé publique → JWK const jwk = await exportJWK(publicKey); // kid STABLE et déterministe : l'empreinte RFC 7638 du JWK. // Régénérer le kid à chaque démarrage casserait la vérification côté // Moodle entre deux déploiements — c'est un anti-pattern classique. const kid = await calculateJwkThumbprint(jwk); // SHA-256, base64url jwk.kid = kid; jwk.alg = "RS256"; jwk.use = "sig"; // Clé privée → PEM PKCS#8 const privatePem = await exportPKCS8(privateKey); mkdirSync(keysDir, { recursive: true }); writeFileSync(privatePath, privatePem, { mode: 0o600 }); writeFileSync(publicPath, JSON.stringify(jwk, null, 2) + "\n"); console.log("Clés générées :"); console.log(` ${privatePath}`); console.log(` ${publicPath}`); console.log(` kid = ${kid}`); } main().catch((err) => { console.error(err); process.exit(1); });

Ajoutez le script et l’exclusion Git :

// package.json (extrait — section "scripts") { "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "generate-keys": "tsx scripts/generate-keys.ts", "debug:token": "node --env-file=.env.local --import tsx scripts/print-token.ts" } }
# .gitignore (à ajouter) keys/

Puis :

npm run generate-keys # Clés générées : # /home/you/quizlab/keys/private.pem # /home/you/quizlab/keys/public.jwk.json # kid = 4Fzq1Yl0V8mMWQnH_c3kM9dZ0aBqT6cW7yPiJ2eR5xg

💡 Pour un dev React : le kid (key id) joue le même rôle qu’un hash de contenu dans un nom de bundle : il identifie une version précise de la clé. Quand Moodle reçoit un JWT signé par l’outil, il lit le kid dans l’en-tête du JWT et cherche la clé correspondante dans notre JWKS. C’est ce qui permet la rotation de clés sans interruption (section 13) : on publie deux clés, on signe avec la nouvelle, les vérificateurs choisissent la bonne grâce au kid.


3. Configuration et registre des plateformes

3.1 La configuration de l’outil

Centralisons la lecture de l’environnement dans un module unique, avec échec immédiat et explicite si une variable manque :

// lib/lti/config.ts // // Configuration de l'outil QuizLab : URL publique, secret de session, // paire de clés RSA. Charge tout au premier import et échoue vite si // une variable d'environnement manque. import { readFileSync } from "node:fs"; import path from "node:path"; function requireEnv(name: string): string { const value = process.env[name]; if (!value) { throw new Error(`Variable d'environnement manquante : ${name}`); } return value; } function loadPrivateKeyPem(): string { // En prod : la clé directement dans l'env (les \n sont échappés en \\n) if (process.env.TOOL_PRIVATE_KEY) { return process.env.TOOL_PRIVATE_KEY.replace(/\\n/g, "\n"); } // En dev : un fichier sur disque const keyPath = requireEnv("TOOL_PRIVATE_KEY_PATH"); return readFileSync(path.resolve(process.cwd(), keyPath), "utf8"); } export interface PublicJwk { kty: string; n: string; e: string; kid: string; alg: string; use: string; } function loadPublicJwk(): PublicJwk { const raw = process.env.TOOL_PUBLIC_JWK ? process.env.TOOL_PUBLIC_JWK : readFileSync( path.resolve(process.cwd(), requireEnv("TOOL_PUBLIC_JWK_PATH")), "utf8", ); const jwk = JSON.parse(raw) as PublicJwk; if (!jwk.kid || !jwk.n) { throw new Error("JWK publique invalide : kid ou n manquant."); } return jwk; } export const toolConfig = { /** URL publique de l'outil, sans slash final. */ baseUrl: requireEnv("TOOL_BASE_URL").replace(/\/$/, ""), /** Secret HMAC (cookies state + session). */ sessionSecret: requireEnv("SESSION_SECRET"), /** Clé privée RSA au format PEM PKCS#8. */ privateKeyPem: loadPrivateKeyPem(), /** Clé publique au format JWK (exposée sur /api/lti/jwks). */ publicJwk: loadPublicJwk(), } as const; /** Le kid de la clé courante de l'outil. */ export const toolKid: string = toolConfig.publicJwk.kid; /** URL de redirection OIDC de l'outil (le redirect_uri enregistré dans Moodle). */ export const launchUrl = `${toolConfig.baseUrl}/api/lti/launch`;

3.2 La clé privée, importée une seule fois

// lib/lti/keys.ts // // Import (et mise en cache) de la clé privée de l'outil pour signer // avec jose. Le type est inféré depuis importPKCS8 pour rester // compatible entre les versions de jose (KeyLike en v5, CryptoKey en v6). import { importPKCS8 } from "jose"; import { toolConfig } from "./config"; type ToolPrivateKey = Awaited<ReturnType<typeof importPKCS8>>; let cached: Promise<ToolPrivateKey> | undefined; export function getToolPrivateKey(): Promise<ToolPrivateKey> { cached ??= importPKCS8(toolConfig.privateKeyPem, "RS256"); return cached; }

3.3 Le registre des plateformes : multi-tenant dès le premier jour

Un outil LTI qui réussit finit toujours par être branché sur plusieurs LMS : plusieurs Moodle, un Canvas, un Brightspace… La spécification identifie une inscription par le couple (iss, client_id) — le même Moodle peut d’ailleurs enregistrer votre outil deux fois avec deux client_id différents. Structurer le code autour d’un registre coûte dix lignes maintenant et vous évite une réécriture plus tard, même si nous n’y mettons qu’une seule entrée aujourd’hui :

// lib/lti/registry.ts // // Registre des plateformes connues, indexé par (issuer, client_id). // Aujourd'hui : une Map alimentée par l'environnement. // Demain : une table `platforms` en base (voir section 13). import { createRemoteJWKSet } from "jose"; export interface LtiPlatform { /** Nom lisible, pour les logs. */ name: string; /** iss des id_token émis par la plateforme (URL racine du Moodle). */ issuer: string; /** client_id attribué à l'outil par cette plateforme. */ clientId: string; /** deployment_ids autorisés pour ce client_id. */ deploymentIds: string[]; /** Endpoint d'autorisation OIDC (mod/lti/auth.php). */ authUrl: string; /** Endpoint de token OAuth2 (mod/lti/token.php). */ tokenUrl: string; /** JWKS de la plateforme (mod/lti/certs.php). */ jwksUrl: string; } const platforms = new Map<string, LtiPlatform>(); const keyOf = (issuer: string, clientId: string) => `${issuer}�${clientId}`; export function registerPlatform(platform: LtiPlatform): void { platforms.set(keyOf(platform.issuer, platform.clientId), platform); } /** * Résout une plateforme. Si client_id est absent (certains LMS ne * l'envoient pas à l'initiation), on retombe sur la première entrée * qui matche l'issuer — acceptable tant qu'un même issuer n'a qu'une * inscription. */ export function getPlatform( issuer: string, clientId?: string, ): LtiPlatform | undefined { if (clientId) return platforms.get(keyOf(issuer, clientId)); for (const platform of platforms.values()) { if (platform.issuer === issuer) return platform; } return undefined; } // --- JWKS distants, mis en cache par URL -------------------------- // createRemoteJWKSet gère lui-même le cache HTTP des clés (rafraîchi // automatiquement si un kid inconnu apparaît, avec un cooldown), mais // il faut réutiliser LA MÊME instance entre les requêtes pour en // profiter — d'où cette Map au niveau module. type RemoteJwks = ReturnType<typeof createRemoteJWKSet>; const jwksCache = new Map<string, RemoteJwks>(); export function getPlatformJwks(platform: LtiPlatform): RemoteJwks { let jwks = jwksCache.get(platform.jwksUrl); if (!jwks) { jwks = createRemoteJWKSet(new URL(platform.jwksUrl)); jwksCache.set(platform.jwksUrl, jwks); } return jwks; } // --- Amorçage depuis l'environnement ------------------------------ if (process.env.LTI_ISSUER) { registerPlatform({ name: "Moodle principal", issuer: process.env.LTI_ISSUER, clientId: process.env.LTI_CLIENT_ID ?? "", deploymentIds: (process.env.LTI_DEPLOYMENT_ID ?? "") .split(",") .map((s) => s.trim()) .filter(Boolean), authUrl: process.env.LTI_AUTH_URL ?? "", tokenUrl: process.env.LTI_TOKEN_URL ?? "", jwksUrl: process.env.LTI_JWKS_URL ?? "", }); }

⚠️ Piège : ne créez jamais un createRemoteJWKSet par requête. L’instance embarque un cache des clés récupérées et un mécanisme de rafraîchissement contrôlé (si un JWT arrive avec un kid inconnu, jose retélécharge le JWKS, mais pas plus d’une fois par fenêtre de cooldown). Une instance par requête = un GET vers certs.php par launch = vous faites du déni de service sur votre propre Moodle, et un Moodle lent fait planter vos launches.


4. Exposer la clé publique : /api/lti/jwks

C’est la route la plus simple de tout le chapitre, et pourtant elle est vitale : Moodle la consultera pour vérifier la signature de nos réponses Deep Linking et de nos assertions client. Le format est celui d’un JWK Set  : un objet { "keys": [...] }.

// app/api/lti/jwks/route.ts // // JWKS public de l'outil. Moodle est configuré avec // "Public key type = Keyset URL" pointant ici (section 8). import { NextResponse } from "next/server"; import { toolConfig } from "@/lib/lti/config"; export async function GET(): Promise<NextResponse> { return NextResponse.json( { keys: [toolConfig.publicJwk], }, { headers: { // Moodle peut mettre cette réponse en cache sans risque : // en cas de rotation on publie l'ancienne ET la nouvelle clé // pendant la période de transition (section 13). "Cache-Control": "public, max-age=3600", }, }, ); }

Testez immédiatement à travers le tunnel :

curl -s https://tool.example.com/api/lti/jwks | python3 -m json.tool # { # "keys": [ # { # "kty": "RSA", # "n": "xGOr-H7A-PWG...", # "e": "AQAB", # "kid": "4Fzq1Yl0V8mMWQnH_c3kM9dZ0aBqT6cW7yPiJ2eR5xg", # "alg": "RS256", # "use": "sig" # } # ] # }

Trois attributs sont non négociables : kid (voir section 2.4), alg: "RS256" (le seul algorithme obligatoire en LTI 1.3) et use: "sig" (clé de signature, pas de chiffrement). Le tableau keys en contiendra deux pendant les rotations.

⚠️ Piège : n’exposez ici que des champs publics (kty, n, e, kid, alg, use). Si vous construisez le JWK à la volée depuis la clé privée avec exportJWK(privateKey), vous publierez les composantes privées (d, p, q, …) — c’est-à-dire la clé privée elle-même, en JSON, sur Internet. C’est exactement pourquoi notre script de génération écrit un fichier JWK dérivé de la clé publique et que la route ne touche jamais à private.pem.


5. L’initiation OIDC : /api/lti/login

5.1 Le flow en séquence

Quand un utilisateur clique sur l’activité QuizLab dans Moodle, Moodle ne nous envoie pas directement l’identité. Il commence par frapper à notre porte (third-party initiated login) pour nous dire « prépare-toi, quelqu’un arrive de chez moi » — et c’est nous qui déclenchons alors la demande d’authentification, avec notre propre state et notre propre nonce. C’est ce qui empêche un attaquant de forger un lancement.

5.2 Le module state + session

Avant la route, écrivons le module qui fabrique et vérifie nos deux jetons internes signés HS256 avec SESSION_SECRET :

  • le state : un JWT courte durée (5 min) qui voyage deux fois — comme paramètre state du flow OIDC (Moodle nous le renverra tel quel) et comme cookie de corrélation. Il embarque le nonce attendu et les paramètres du login initial (ce qui rendra possible le plan de secours cookies de la section 9) ;
  • la session applicative : un JWT de 2 h posé en cookie après un launch réussi, qui contient tout ce dont les pages et les routes API ont besoin (identité, contexte, endpoints AGS/NRPS).
// lib/lti/session.ts // // Jetons internes de l'outil, signés HS256 avec SESSION_SECRET : // - state OIDC (cookie qzl_state + paramètre state), durée 5 min // - session applicative (cookie qzl_session), durée 2 h import { SignJWT, jwtVerify, type JWTPayload } from "jose"; import { cookies } from "next/headers"; import { randomUUID } from "node:crypto"; import { toolConfig } from "./config"; const secret = new TextEncoder().encode(toolConfig.sessionSecret); export const STATE_COOKIE = "qzl_state"; export const SESSION_COOKIE = "qzl_session"; // ------------------------------------------------------------------ // 1) Le state OIDC // ------------------------------------------------------------------ export interface LoginState { /** Le nonce que l'on s'attend à retrouver dans l'id_token. */ nonce: string; /** Plateforme résolue à l'initiation. */ issuer: string; clientId: string; /** Paramètres du login initial, conservés pour le re-lancement * en nouvelle fenêtre si les cookies sont bloqués (section 9). */ loginHint: string; messageHint?: string; targetLinkUri?: string; } export async function createStateToken(state: LoginState): Promise<string> { return new SignJWT({ ...state }) .setProtectedHeader({ alg: "HS256" }) .setJti(randomUUID()) // rend chaque state unique .setIssuedAt() .setExpirationTime("5m") .sign(secret); } export async function verifyStateToken(token: string): Promise<LoginState> { const { payload } = await jwtVerify(token, secret, { algorithms: ["HS256"], }); return payload as unknown as LoginState; } // ------------------------------------------------------------------ // 2) La session applicative // ------------------------------------------------------------------ export interface QuizLabSession { /** Identifiant opaque et stable de l'utilisateur chez la plateforme. */ sub: string; name?: string; email?: string; /** URIs de rôles LIS (voir lib/lti/roles.ts). */ roles: string[]; /** De quelle inscription vient ce launch. */ issuer: string; clientId: string; deploymentId: string; messageType: string; /** Contexte (le cours Moodle). */ contextId?: string; contextTitle?: string; /** Le placement précis (l'activité) dans le cours. */ resourceLinkId?: string; /** Paramètres custom (ex. quiz_id posé par le Deep Linking). */ custom?: Record<string, string>; /** Endpoints AGS extraits du claim lti-ags/claim/endpoint. */ ags?: { lineitems?: string; lineitem?: string; scopes: string[] }; /** Endpoint NRPS extrait du claim lti-nrps/claim/namesroleservice. */ nrps?: { url: string; versions: string[] }; /** Présent uniquement pour un launch LtiDeepLinkingRequest. */ deepLinking?: { returnUrl: string; data?: string }; } export async function createSessionToken( session: QuizLabSession, ): Promise<string> { return new SignJWT({ ...session } as unknown as JWTPayload) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() .setExpirationTime("2h") .sign(secret); } /** * Lit et vérifie la session depuis le cookie. Utilisable dans les * Server Components et les route handlers (cookies() de next/headers). * Retourne null si absente, invalide ou expirée. */ export async function readSession(): Promise<QuizLabSession | null> { const store = await cookies(); // Next 15 : cookies() est asynchrone const token = store.get(SESSION_COOKIE)?.value; if (!token) return null; try { const { payload } = await jwtVerify(token, secret, { algorithms: ["HS256"], }); return payload as unknown as QuizLabSession; } catch { return null; // expirée ou falsifiée → comme si elle n'existait pas } }

💡 Pour un dev React : ce module est l’équivalent artisanal de ce que next-auth appelle la JWT session strategy : pas de table sessions, l’état est dans le cookie, signé pour être infalsifiable (mais pas chiffré : n’y mettez rien de secret, un utilisateur peut décoder son propre JWT en base64). Notez le algorithms: ["HS256"] passé à jwtVerify : on épingle l’algorithme attendu pour neutraliser toute la famille d’attaques par confusion d’algorithme (alg: none, RS256→HS256, etc.).

5.3 La route d’initiation

Point crucial : Moodle envoie l’initiation en POST (application/x-www-form-urlencoded) par défaut, mais la spécification autorise aussi le GET, et d’autres plateformes (ou le bouton de re-lancement de notre section 9) utiliseront le GET. La route accepte donc les deux et normalise vers URLSearchParams.

// app/api/lti/login/route.ts // // Initiation OIDC (third-party initiated login). // Moodle nous prévient qu'un launch arrive ; on répond par une // redirection 303 vers son endpoint d'autorisation, avec NOTRE state // et NOTRE nonce, et on pose le cookie de corrélation. import { NextRequest, NextResponse } from "next/server"; import { randomUUID } from "node:crypto"; import { toolConfig, launchUrl } from "@/lib/lti/config"; import { getPlatform } from "@/lib/lti/registry"; import { createStateToken, STATE_COOKIE } from "@/lib/lti/session"; export async function GET(req: NextRequest): Promise<NextResponse> { return handleLogin(req.nextUrl.searchParams); } export async function POST(req: NextRequest): Promise<NextResponse> { const form = await req.formData(); const params = new URLSearchParams(); form.forEach((value, key) => { if (typeof value === "string") params.set(key, value); }); return handleLogin(params); } async function handleLogin(params: URLSearchParams): Promise<NextResponse> { // --- 1. Lire les paramètres de l'initiation --------------------- const iss = params.get("iss"); const loginHint = params.get("login_hint"); const targetLinkUri = params.get("target_link_uri") ?? undefined; const messageHint = params.get("lti_message_hint") ?? undefined; const clientId = params.get("client_id") ?? undefined; const deploymentId = params.get("lti_deployment_id") ?? undefined; if (!iss || !loginHint) { return NextResponse.json( { error: "invalid_request", error_description: "iss et login_hint sont requis." }, { status: 400 }, ); } // --- 2. Résoudre la plateforme dans le registre ----------------- const platform = getPlatform(iss, clientId); if (!platform) { return NextResponse.json( { error: "unknown_platform", error_description: `Plateforme non enregistrée : ${iss}` }, { status: 400 }, ); } // Vérification opportuniste : si Moodle annonce un deployment_id, // il doit être connu (la vérification ferme aura lieu au launch). if (deploymentId && !platform.deploymentIds.includes(deploymentId)) { return NextResponse.json( { error: "invalid_deployment", error_description: `deployment_id inconnu : ${deploymentId}` }, { status: 400 }, ); } // --- 3. Générer nonce + state ----------------------------------- const nonce = randomUUID(); const state = await createStateToken({ nonce, issuer: platform.issuer, clientId: platform.clientId, loginHint, messageHint, targetLinkUri, }); // --- 4. Construire l'URL d'autorisation Moodle ------------------ const authUrl = new URL(platform.authUrl); authUrl.searchParams.set("scope", "openid"); // imposé par OIDC authUrl.searchParams.set("response_type", "id_token"); // flow implicite : l'id_token arrive directement authUrl.searchParams.set("response_mode", "form_post"); // ... par POST de formulaire, pas dans un fragment # authUrl.searchParams.set("prompt", "none"); // ne JAMAIS afficher de page de login Moodle authUrl.searchParams.set("client_id", platform.clientId); authUrl.searchParams.set("redirect_uri", launchUrl); // doit être EXACTEMENT l'URI enregistrée dans Moodle authUrl.searchParams.set("login_hint", loginHint); // opaque : on le renvoie tel quel authUrl.searchParams.set("state", state); authUrl.searchParams.set("nonce", nonce); if (messageHint) { // Opaque aussi : Moodle y encode l'activité/le type de message visés authUrl.searchParams.set("lti_message_hint", messageHint); } // --- 5. Rediriger + poser le cookie de corrélation -------------- const res = NextResponse.redirect(authUrl, 303); res.cookies.set(STATE_COOKIE, state, { httpOnly: true, // inaccessible à document.cookie secure: true, // HTTPS uniquement (requis avec SameSite=None) sameSite: "none", // le POST du launch viendra d'un autre site (Moodle) partitioned: true, // cookie CHIPS — voir section 9 path: "/api/lti", // inutile ailleurs maxAge: 300, // 5 minutes : le temps d'un aller-retour, pas plus }); return res; }

Déroulons les points qui méritent explication :

  • prompt=none : l’utilisateur a déjà une session sur Moodle (il vient d’y cliquer sur une activité). On interdit à Moodle d’afficher quoi que ce soit d’interactif ; s’il ne peut pas authentifier silencieusement, il renverra une erreur OIDC (login_required) que notre launch saura afficher.
  • response_mode=form_post : l’id_token fait plusieurs kilooctets ; il reviendra dans le corps d’un POST plutôt que dans un fragment d’URL. C’est aussi pour cela que notre route de launch est un handler POST.
  • redirect_uri : comparée caractère par caractère avec la liste enregistrée dans Moodle. Un / final de trop et Moodle refuse.
  • Le state est un JWT signé plutôt qu’un simple UUID : la valeur qui revient dans le POST du launch est ainsi vérifiable en elle-même (émise par nous, non expirée) en plus de la corrélation par cookie. Le cookie prouve que ce navigateur a initié le flow (anti-CSRF) ; la signature prouve que notre serveur l’a initié. Et comme le state transporte les paramètres du login, il servira de bouée de sauvetage quand les cookies seront bloqués (section 9).
  • SameSite=None sur le cookie : le POST final vers /api/lti/launch sera une requête cross-site — elle est émise par une page servie par moodle.example.com (le formulaire auto-soumis de auth.php) à destination de tool.example.com. Avec SameSite=Lax (le défaut des navigateurs), le cookie ne serait pas envoyé sur ce POST cross-site et la corrélation échouerait systématiquement. SameSite=None exige Secure, donc HTTPS — la boucle est bouclée avec notre tunnel.
  • 303 See Other : sémantiquement « va voir ailleurs avec un GET », le code correct pour rediriger après un POST.

⚠️ Piège : ne validez jamais un launch sur la seule foi du paramètre state sans corrélation externe (cookie ou store serveur). Un state signé prouve qu’il vient de votre serveur, mais pas qu’il a été émis pour ce navigateur : un attaquant pourrait initier un flow lui-même, obtenir un state valide, et le faire consommer à sa victime (login CSRF — la victime se retrouve connectée au compte de l’attaquant dans l’outil). Le cookie de corrélation est la vraie protection ; le fallback sans cookie de la section 9 devra en tenir compte.

📚 Aller plus loin : la section Security Framework §5.1.1  décrit formellement ce « OpenID Connect Launch Flow » et le rôle exact de login_hint et lti_message_hint (opaques pour l’outil, ils permettent à Moodle de retrouver l’utilisateur et l’activité quand la requête d’autorisation lui revient).