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
- Objectif et architecture
- Mise en place du projet
- Configuration et registre des plateformes
- Exposer la clé publique :
/api/lti/jwks - L’initiation OIDC :
/api/lti/login - Le launch :
/api/lti/launch - La page React
/lti/app - Enregistrer l’outil dans Moodle 5.2
- Le problème des cookies en iframe
- Deep Linking : proposer du contenu à Moodle
- AGS : écrire dans le carnet de notes
- NRPS : lire le roster du cours
- Checklist de mise en production
- Refaire la même chose avec ltijs
- 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ément | Valeur |
|---|---|
| Outil (prod) | https://tool.example.com |
| Outil (dev) | tunnel HTTPS (ngrok / cloudflared) vers localhost:3000 |
| Plateforme Moodle | https://moodle.example.com |
| Endpoint d’autorisation Moodle | https://moodle.example.com/mod/lti/auth.php |
| Endpoint de token Moodle | https://moodle.example.com/mod/lti/token.php |
| JWKS Moodle | https://moodle.example.com/mod/lti/certs.php |
client_id | a1b2c3d4e5f6g7h |
deployment_id | 1 |
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 utiliseronscreateRemoteJWKSet,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 :
- 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.
- Le trafic navigateur applicatif : une fois la session posée, votre app React fonctionne normalement (fetch vers vos propres routes API).
- 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 tsxjose 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.localet la configuration de l’outil dans Moodle (Tool URL, Initiate login URL, Redirection URI, Keyset URL). Unredirect_uriqui 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’
issuerde Moodle est l’URL du site sans slash final et telle qu’elle apparaît dans$CFG->wwwroot. Si votre Moodle est servi enhttps://moodle.example.com/mais que vous mettezhttps://moodle.example.com/moodle(ou l’inverse), la vérificationissuerdejwtVerifyé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 lekiddans 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 aukid.
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
createRemoteJWKSetpar 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 unkidinconnu,joseretélécharge le JWKS, mais pas plus d’une fois par fenêtre de cooldown). Une instance par requête = un GET verscerts.phppar 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 avecexportJWK(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
statedu flow OIDC (Moodle nous le renverra tel quel) et comme cookie de corrélation. Il embarque lenonceattendu 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-authappelle la JWT session strategy : pas de tablesessions, 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 lealgorithms: ["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_tokenfait 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 handlerPOST.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=Nonesur le cookie : le POST final vers/api/lti/launchsera une requête cross-site — elle est émise par une page servie parmoodle.example.com(le formulaire auto-soumis deauth.php) à destination detool.example.com. AvecSameSite=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=NoneexigeSecure, 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
statesans corrélation externe (cookie ou store serveur). Unstatesigné 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_hintetlti_message_hint(opaques pour l’outil, ils permettent à Moodle de retrouver l’utilisateur et l’activité quand la requête d’autorisation lui revient).