NestJSでLTI連携をハンズオン

カバー

はじめに

LTI(Learning Tools Interoperability)は、LTIプロトコルを利用して、LMS(Learning Management System)と各種ツールを相互連携させる仕組みです。
これにより、各種ツールごとにユーザーや授業を管理する必要がなくなり、LMSだけで全体を一元管理できます。
当社の製品であるalpha Vclass Cloud では、LMSとの連携が可能であり、教員や学生の個別の登録や授業の登録が不要になり、事前準備の手間を大幅に軽減できます。
本記事では、NestJSを使用してLTI Advantageを活用したLTI連携の実装手順を紹介しています。NestJSについてはこちらの記事を併せてご参照ください。

alpha Vclass Cloud とは

alpha Vclass Cloud(アルファ ブイクラス クラウド)は、マルチOS・マルチデバイス・マルチロケーションに対応したクラウド型授業支援サービスです。
学生端末のデスクトップ画面を教員端末のデスクトップ画面に表示し、オンライン授業を実施できます。
詳しくはこちらをご参照ください。

LTI連携によるメリット

今日では、さまざまな授業支援システムやツールがあります。
LTI連携を利用せず、各システムやツールが独立している場合、管理者はそれぞれにユーザー情報や授業情報を登録する必要があります。
また、学生や教員はそれぞれ個別にログインを行う必要があります。

LTI連携しない場合の利用イメージ

LTI連携を利用すると、管理者はユーザー登録の手間が省け、学生や教員はLMSにログインするだけで、利用可能なシステムをすぐに使えるようになります。

LTI連携した場合の利用イメージ

LTI連携の処理の流れ

LTI連携での処理の流れについて説明します。

  1. 利用者がLTI対応のプラットフォーム(LMS)にアクセスすると、認証リクエストが開始されます。
  2. LMSはLTI 1.3のLaunch Requestを生成し、ツール(リソース提供者)に送信します。
  3. ツールは、LMSからのリクエストを受け取り、バリデーションを行います。
  4. Launch Requestがバリデーションをパスすると、ツールはLMSに対してアクセストークンを発行します。
  5. LMSは、アクセストークンを使用してツールのAPIエンドポイントなどへリクエストを送信します。
  6. ツールはリクエストを処理し、結果をLMSに返します。
  7. LMSは結果を利用者に表示したり、適切な処理を実行したりします。

ツールの作成

ここからは実際に小テストを提供するツールを作成しながら実装を見ていきます。
今回作成するツールの仕様とその実現方法は以下の通りです。

実施する小テストの種類を選択できる

管理者が、受講者の実施する小テストの種類を選択することができます。
この仕様を実現するために、Deep Linkingを利用します。
Deep Linkingは、ツールからLMSに対して特定のリソースを選択・リンクするためのプロトコルです。
これにより、ユーザーは外部ツールから特定のコンテンツ(例えばクイズ・課題・ビデオ・ドキュメントなど)をLMSのコースにシームレスに埋め込むことができます。

小テストを受講しているユーザーを一覧で表示できる

学生は、小テストを受講している他のユーザーを一覧で確認することができます。
この仕様を実現するために、NRPS(Names and Role Provisioning Services)を利用します。
NRPSは、ツールがLMSからコース参加者の名前と役割情報を取得するためのサービスです。
NRPSを使用することで、外部ツールはLMS内のユーザーデータにアクセスできます。

プロジェクト作成

まず初めに@nestjs/cliをインストールします。こちらをインストールすることで、NestJSのコマンドが入力できるようになります。

$ npm i -g @nestjs/cli

次に、プロジェクトを作成します。今回はプロジェクト名をlti-sampleにします。

$ nest new lti-sample

初期設定

今回はMVC(Model-View-Controller)のパターンで実装します。
ビューエンジンはhbs(Handlebars)を利用します。

まず、必要なライブラリをインストールします。

npm i @nestjs/config @nestjs/jwt @nestjs/passport passport-jwt axios rasha uuid hbs

次に、ビューエンジンの設定をおこないます。main.tsを以下のように修正します。

./src/main.ts
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// 利用するビューを格納したディレクトリ
app.setBaseViewsDir(join(__dirname, '..', 'views'));
// 利用するビューエンジンの設定
app.setViewEngine('hbs');
await app.listen(3000);
}
bootstrap();

次に、process.env.{変数名}で、.envに記載した環境変数を参照できるようにするため、app.module.tsを以下のように修正します。

./src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
imports: [
/* .envファイルから設定を読み込む */
ConfigModule.forRoot({
isGlobal: true,
envFilePath: `.env`,
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

Controllerの実装

LTI連携に必要なAPIを実装します。
まず、LTI連携用のモジュールを作成します。

$ nest g res lti

次に、lti.controller.tsを修正してAPIを定義します。

  • 公開鍵セットを返却するAPI
  • OpenID Connectを受け付けるAPI
  • IDトークンを受け取り、実施する小テストの種類を選択するページ、または小テストを表示するAPI
  • 選択した小テストの種類を保存するAPI
./src/lti/lti.controller.ts
import * as queryString from 'querystring';
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Render,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { Response } from 'express';
import { CreateDeepLinkingDto, LtiOidcDto } from './dto/lti.dto';
import { LtiGuard } from './lti.guard';
import { LtiService } from './lti.service';
@Controller('lti')
export class LtiController {
constructor(private readonly ltiService: LtiService) {}
/**
* 公開鍵セットを返却する
* LMSがAPIサーバで発行したJWTを検証するために必要
*/
@Get('jwks')
async jwks() {
return this.ltiService.getJwks();
}
/**
* OpenIDConnectを実行する
* リダイレクト先はLMS側の認証URLとなる
*/
@Post('oidc')
@HttpCode(HttpStatus.FOUND)
async oidc(@Body() body: LtiOidcDto, @Res() res: Response) {
const redirectUrl = await this.ltiService.oidc(body);
return res.redirect(redirectUrl);
}
/**
* LMS側の認証が行われ、OpenIDConnect完了後に実行される
* リクエストに付与されたIDトークンを解析して表示するページを変更する
*/
@Post('redirect')
@UseGuards(LtiGuard)
@Render('lti')
async redirect(@Req() req) {
const param = await this.ltiService.createResourceLink(req.user);
const query = queryString.stringify(req.body);
return { param: param, query: query };
}
/**
* Deep LinkingでLMSに渡すJWTを作成する
*/
@Post('select')
select(@Body() body: CreateDeepLinkingDto) {
const jwt = this.ltiService.createDeepLinkingResponse(body);
return jwt;
}
}

次に、APIで利用するリクエストボディの型を定義します。

.src/lti/dto/lti.dto.ts
import { Expose } from 'class-transformer';
/**
* OpenID Connect用Dto
*
*/
export class LtiOidcDto {
/**
* リクエストを発行したクライアントの一意な識別子
*/
iss: string;
/**
* 表示するLTIリソースの実際のエンドポイント
*/
targetLinkUri: string;
/**
* エンドユーザーがログインに使用するログイン識別子に関する認可サーバへのヒント
*/
loginHint?: string;
/**
* login_hintと一緒に使用することで、実際に起動されるLTIメッセージに関する情報を伝えることが可能
* 指定された場合は加工せずそのまま返却する
*/
@Expose({ name: 'lti_message_hint' })
ltiMessageHint?: string;
/**
* クライアント識別子
*/
@Expose({ name: 'client_id' })
clientId?: string;
/**
* LMSによって発行されるデプロイメントID
*/
@Expose({ name: 'lti_deployment_id' })
ltiDeploymentId?: string;
}
/**
* Deep Link作成Dto
*/
export class CreateDeepLinkDto {
/**
* Deep Link作成に必要なデータ
*/
data: any;
/**
* Deep Linkを作成したあとのリダイレクト先
*/
deepLinkReturnUrl: string;
}

次に、表示する小テストのビューを実装します。

lti-sampleフォルダ直下にviewsフォルダを作成します。

cd lti-sample
mkdir views

作成したviewsフォルダ配下に、lti.hbsファイルを作成します。

./views/lti.hbs
<html>
<head>
<meta charset='UTF-8' />
<title>{{param.title}}</title>
</head>
<body>
{{#if param.deepLinkReturnUrl}}
<!-- Deep Linkingリクエストのときは種類選択ページを表示 -->
<h3 style='text-align: center;'>LTI連携 実施する種類選択</h3>
<div>
<h1>実施する小テストの種類を選択してください。</h1>
<div>
<select id="kind">
<option value="1">小テスト1</option>
<option value="2">小テスト2</option>
<option value="3">小テスト3</option>
</select>
</div>
<button id='submit'>決定</button>
<!-- 生成したDeep LinkをLMSに渡すForm -->
<form id='lti-submit' action='{{param.deepLinkReturnUrl}}' method='post'>
<input type='hidden' id="JWT" name='JWT' value='{{jwt}}' />
</form>
</div>
<script type='text/javascript'>
// 選択した種類を記憶するイベントリスナーを登録
let kind = "1";
let select = document.getElementById("kind");
select.addEventListener('change', function(e) {
kind = e.target.value
})
// 決定ボタンを押したときにおこなうコールバック
async function sendData() {
const items = [
{
type: "ltiResourceLink",
title: "小テスト",
url: `http://192.168.1.1:3000/lti`,
custom: { kind: kind }
}
];
const resource = {
iss: "{{param.iss}}",
aud: "{{param.aud}}",
nonce: "abcd1234",
"https://purl.imsglobal.org/spec/lti/claim/deployment_id": "{{param.deploymentId}}",
"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-dl/claim/data": undefined,
"https://purl.imsglobal.org/spec/lti-dl/claim/content_items": items,
};
if ("{{param.deepLinkData}}") {
resource[`https://purl.imsglobal.org/spec/lti-dl/claim/data`] =
JSON.parse("{{param.deepLinkData}}");
}
const body = {
deepLinkReturnUrl: "{{param.deepLinkReturnUrl}}",
data: resource,
};
// APIを実行してDeep Link作成
const response = await fetch("http://192.168.1.1:3000/lti/select", {
method: "POST",
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json; charset=utf-8",
},
});
// FormのinputをsubmitしてDeep LinkをLMSに送信
const inputJwt = document.getElementById("JWT");
inputJwt.value = await response.text();
document.getElementById("lti-submit").submit();
};
// 決定ボタンをクリックしたときのイベントリスナーを登録
const btn = document.getElementById("submit");
btn.addEventListener("click", function () { sendData(); });
</script>
{{else}}
<!-- Launch Requestのときは、Deep Linkingで選択した種類の小テストを表示 -->
<h3 style='text-align: center;'>{{param.title}}の小テスト{{param.kind}}</h3>
<!-- ツールを起動したユーザー名を取得できる -->
<h4>受講者: {{param.currentUserName}}</h4>
<!-- LMSに登録されている、このコースを受けているメンバを取得できる -->
<ul>
このコースを受けているメンバ
{{#each param.users}}
<li>{{this}}</li>
{{/each}}
</ul>
{{/if}}
</body>
</html>

Guardの実装

OpenID Connectで利用するIDトークンを検証するGuardを実装します。

次のコマンドを実行してソースファイルを作成します。

nest g gu lti

次に、lti.guard.tsを以下のように修正します。

lti.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LtiGuard extends AuthGuard('lti') {
/**
* コンストラクタ
*/
constructor() {
super();
}
}

次に、以下コマンドを実行してストラテジーファイルを作成します。

touch ./src/lti/lti.strategy.ts

作成したlti.strategy.tsを以下のように修正します。

.src/lti/lti.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { LtiService } from '../lti/lti.service';
import { LtiPayload } from './dto/lti.dto';
@Injectable()
export class LtiStrategy extends PassportStrategy(Strategy, 'lti') {
/**
* コンストラクタ
*/
constructor(private readonly ltiService: LtiService) {
super({
// IDトークンを検証するメソッドを登録
secretOrKeyProvider: (_req, rawJwtToken, done) => {
ltiService.validateToken(rawJwtToken, (err, key) => {
if (err) {
return done(err);
}
return done(null, key);
});
},
// JWTをリクエストボディのid_tokenから取得するように設定
jwtFromRequest: ExtractJwt.fromBodyField('id_token'),
});
}
/**
* LMSから受け取ったペイロードそのままでは使いづらいため独自に定義したインタフェースに変換
*/
validate(payload: unknown): unknown {
const context =
payload['https://purl.imsglobal.org/spec/lti/claim/context'];
const namesRoles =
payload[
'https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice'
];
const deepLinkSettings =
payload[
'https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings'
];
const custom = payload['https://purl.imsglobal.org/spec/lti/claim/custom'];
const ltiPayload: LtiPayload = {
iss: payload['iss'],
aud: payload['aud'],
sub: payload['sub'],
deploymentId:
payload['https://purl.imsglobal.org/spec/lti/claim/deployment_id'],
roles: payload['https://purl.imsglobal.org/spec/lti/claim/roles'],
targetLinkUri:
payload['https://purl.imsglobal.org/spec/lti/claim/target_link_uri'],
name: payload['name'],
email: payload['email'],
context: context ? { title: context.title } : undefined,
custom: custom
? {
kind: custom.kind,
}
: undefined,
namesRoles: namesRoles
? { contextMembershipsUrl: namesRoles.context_memberships_url }
: undefined,
deepLinkSettings: deepLinkSettings
? {
deepLinkReturnUrl: deepLinkSettings.deep_link_return_url,
data: deepLinkSettings.data,
}
: undefined,
};
return ltiPayload;
}
}

次に、作成したストラテジーでLtiServiceクラスを呼び出せるようにlti.module.tsを修正します。

.src/lti/lti.module.ts
import { Module } from '@nestjs/common';
import { LtiController } from './lti.controller';
import { LtiService } from './lti.service';
@Module({
controllers: [LtiController],
providers: [LtiService],
exports: [LtiService], // 追記
})
export class LtiModule {}

次に、作成したストラテジーを利用するようにapp.module.tsを修正します。

.src/app.module.ts
import { AppService } from './app.service';
import { LtiModule } from './lti/lti.module';
import { LtiStrategy } from './lti/lti.strategy'; // 追記
@Module({
imports: [
/* .envファイルから設定を読み込む */
ConfigModule.forRoot({
isGlobal: true,
envFilePath: `.env`,
}),
LtiModule,
],
controllers: [AppController],
providers: [AppService, LtiStrategy], // 追記
})
export class AppModule {}

Serviceの実装

Controllerで呼び出しているServiceのメソッドを実装します。
バリデーションなどのエラー処理は省略しています。

.src/lti/dto/lti.service.ts
import * as crypto from 'crypto';
import * as queryString from 'querystring';
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import axios from 'axios';
import * as jwt from 'jsonwebtoken';
import * as Jwk from 'rasha';
import { v4 as uuidv4 } from 'uuid';
import { CreateDeepLinkingDto, LtiOidcDto, LtiPayload } from './dto/lti.dto';
@Injectable()
export class LtiService {
constructor(private readonly jwtService: JwtService) {}
/**
* LTI連携用の公開鍵セットを生成する
*/
getJwks() {
// JWKを生成する
const rsaKey = crypto.createPublicKey(process.env.JWT_PUBLIC_KEY);
const jwk = rsaKey.export({ format: 'jwk' });
// cryptoで作成したJWKにはalg, kid, useが含まれないためそれらを追加したオブジェクトを生成
const res = {
kty: jwk.kty,
alg: 'RS256',
kid: 'test_kid',
use: 'sig',
e: jwk.e,
n: jwk.n,
};
return {
keys: [res],
};
}
/**
* OpenIDConnect要求を処理する
*/
async oidc(dto: LtiOidcDto) {
const nonce = uuidv4().replace(/-/g, '');
const nonceBytes = Uint8Array.from(Buffer.from(nonce, 'hex'));
// dto内のiss・clientIdやltiDeploymentIdを使ってOpenIDConnectを要求したプラットフォームを特定する
// 今回は省略
const authParam = {
scope: 'openid',
response_type: 'id_token',
response_mode: 'form_post',
prompt: 'none',
client_id: dto.client_id,
redirect_uri: `http://192.168.1.1:3000/lti/redirect`, // LMS側がIDトークンを送信する先を指定する。LMSが接続できるURLである必要がある
state: `state-${uuidv4()}`,
nonce: nonceBytes.toString(),
login_hint: dto.login_hint,
lti_message_hint: dto.lti_message_hint.toString(),
};
return (
// 今回はプラットフォームの情報を.envファイルから取得
process.env.LTI_PLATFORM_AUTH_URL + '?' + queryString.stringify(authParam)
);
}
/**
* APIを実行時に付与されたIDトークンを検証する
*/
async validateToken(token: string, callback) {
const decoded = jwt.decode(token, { complete: true });
const payload = decoded.payload as jwt.JwtPayload;
// payload内のiss・audやdeploymentIdを使ってAPIを実行したプラットフォームを特定する
// 今回は省略
const kid = decoded.header.kid;
let jwk = '';
try {
// LMSが公開している公開鍵セットURLにGETを実行
const jwks = await axios.get(process.env.LTI_PLATFORM_CERT_URL);
jwk = jwks.data.keys.find((key) => key.kid === kid);
if (!jwk) {
callback(-1, null);
return -1;
}
} catch (error) {
callback(-2, null);
return -2;
}
try {
// 取得した公開鍵でIDトークンを検証する
const key = await Jwk.export({ jwk });
jwt.verify(token, key, {
algorithms: [decoded.header.alg as jwt.Algorithm],
clockTimestamp: Date.now() / 1000,
});
// 公開鍵をコールバックに渡して終了
callback(null, key);
return 0;
} catch (error) {
callback(-3, null);
return -3;
}
}
/**
* ResourceLinkを作成する
*/
async createResourceLink(payload: LtiPayload) {
// payload内にあるiss・audやdeploymentIdを利用してAPIを実行したプラットフォームを特定する
// 今回は省略
const targetUsers = await this.getMembersFromNrps(payload);
console.log(targetUsers);
if (payload.custom && payload.custom.kind != null) {
return {
title: payload.context?.title, // LMSのコース名
currentUserName: payload.name, // LTIツールを起動したユーザー名
users: targetUsers, // LMSのコースを受けているメンバ
kind: payload.custom.kind, // Deep Linkingで設定した小テストの種類
};
} else {
return {
title: payload.context?.title,
currentUserName: payload.sub,
users: targetUsers,
iss: payload.aud,
aud: payload.iss,
deploymentId: payload.deploymentId,
deepLinkReturnUrl: payload.deepLinkSettings?.deepLinkReturnUrl,
deepLinkingSettingData: payload.deepLinkSettings?.data,
};
}
}
/**
* Deep LinkingでLMSに渡すJWTを作成する
*/
createDeepLinkingResponse(dto: CreateDeepLinkingDto) {
const jwt = this.jwtService.sign(dto.data);
return jwt;
}
/**
* Names and Role Provisioning Servicesを利用して、LMSの参加メンバーを取得する
* @param payload: LTIのペイロード
*/
private async getMembersFromNrps(payload: LtiPayload) {
// NRPSのAPI実行に必要なトークンを取得する
const confJwt = {
sub: typeof payload.aud === 'string' ? payload.aud : payload.aud[0],
iss: typeof payload.aud === 'string' ? payload.aud : payload.aud[0],
aud: process.env.LTI_PLATFORM_ACCESS_TOKEN_URL,
jti: encodeURIComponent(
[...Array(25)]
.map(() => ((Math.random() * 36) | 0).toString(36))
.join(``),
),
};
try {
const nrpsToken = this.jwtService.sign(confJwt);
const message = {
grant_type: 'client_credentials',
client_assertion_type:
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: nrpsToken,
scope:
'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly',
};
const res = await axios.post(
process.env.LTI_PLATFORM_ACCESS_TOKEN_URL,
queryString.stringify(message),
);
// 取得したトークンをAuthorizationヘッダに付与してNRPSのAPIを実行する
const tokenType = (res.data.token_type as string).replace(
/^[a-z]/g,
(val) => val.toUpperCase(),
);
const lmsAccess = res.data.access_token;
const userRes = await axios.get(
payload.namesRoles?.contextMembershipsUrl,
{
headers: {
Authorization: tokenType + ' ' + lmsAccess,
Accept: 'application/vnd.ims.lti-nrps.v2.membershipcontainer+json',
},
},
);
return userRes.data.members.map((member) => member.name);
} catch (e) {
return [];
}
}
}

次に、JwtServiceの設定をおこないます。lti.module.tsを以下のように修正します。
JWT作成時に利用する秘密鍵・暗号化アルゴリズム・keyidを設定します。

.src/lti/dto/lti.module.ts
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; // 追記
import { JwtModule } from '@nestjs/jwt'; // 追記
import { LtiController } from './lti.controller';
import { LtiService } from './lti.service';
@Module({
// importsを追加
imports: [
JwtModule.registerAsync({
useFactory: async (configService: ConfigService) => {
return {
privateKey: process.env.JWT_PRIVATE_KEY,
signOptions: {
algorithm: 'RS256',
keyid: 'test_kid',
},
};
},
inject: [ConfigService],
}),
],
controllers: [LtiController],
providers: [LtiService],
exports: [LtiService],
})
export class LtiModule {}

LTI連携に必要な情報の取得と.envファイルの作成

今回作成したAPIサーバを、連携するLMSにLTIツールとして登録します。
LMSの例にはMoodleを使用して紹介します。

LTIツールとして登録

  1. Moodleに管理者としてログインし、「サイト管理」 > 「プラグイン」 > 「活動モジュール」 > 「外部ツール」に移動します。
  2. 「ツールを管理する」内にある「ツールを手動設定」のリンクをクリックします。
  3. ツール設定画面が表示されますので、以下のように設定してください。
  • ツール名:サンプルLTIツール
  • ツールURL:LTI連携を有効にするで表示された「ツールURL」
  • LTIバージョン:LTI 1.3
  • 公開鍵タイプ:192.168.1.1:3000/lti/jwks
  • 公開鍵セット:「LTI連携を有効にする」で表示された「公開鍵セットURL」
  • ログイン開始URL:「LTI連携を有効にする」で表示された「ログイン開始URL」
  • リダイレクトURL:「LTI連携を有効にする」で表示された「リダイレクトURI」
  • ツール設定使用:活動チューザまたは事前設定ツールに表示する
  • デフォルト起動コンテナ:新しいウィンドウ
  • ディープリンクをサポートする:チェックを入れる
  • 「サービス」>「IMS LTI氏名およびロールプロビジョニング」:このサービスをプライバシー設定を基にメンバシップ情報を検索するため使用します
  • 「プライバシー」>「ランチャ名をツールと共有する」:常に
  1. 「変更を保存する」をクリックします。

※注: 自己証明書を利用した場合、公開鍵セットURLの指定では正しく動作しないことがあります。その場合は公開鍵タイプRSAキーに設定し、公開鍵を設定してください。

LTI連携に必要な情報の取得

LTI連携に必要な情報をLMSから取得します。

  1. Moodleに管理者としてにログインし、「サイト管理」 > 「プラグイン」 > 「活動モジュール」 > 「外部ツール」に移動します。
  2. 「ツールを管理する」に登録されている「alpha Vclass Cloud」を表示します。
  3. ハンバーガーメニューをクリックします。

.envファイルの作成

  1. 以下コマンドを実行して.envファイルを作成します。
touch .env
  1. 前項で取得した情報を入力します。(例としてMoodleサーバのIPアドレスが192.168.1.100とします)

.env

LTI_PLATFORM_AUTH_URL="http://192.168.1.100/moodle/mod/lti/auth.php"
LTI_PLATFORM_CERT_URL="http://192.168.1.100/moodle/mod/lti/certs.php"
LTI_PLATFORM_ACCESS_TOKEN_URL="http://192.168.1.100/moodle/mod/lti/token.php"

次に、Deep Linkingで利用するJWTの作成に必要な秘密鍵と公開鍵を生成します。
OpenSSLを使用して自己証明書とプライベートキーファイルを生成します。以下のコマンドを使用します。

openssl req -newkey rsa:2048 -nodes -keyout key.pem -x509 -days 365 -out certificate.pem

上記のコマンドを実行すると、いくつかのプロンプトが表示されますので、必要な情報を入力してください。

Country Name (2 letter code) []: [国の略称]
State or Province Name (full name) []: [都道府県名・州名など]
Locality Name (eg, city) []: [都市名等]
Organization Name (eg, company) []: [組織名]
Organizational Unit Name (eg, section) []: [組織内の部署名など]
Common Name (eg, fully qualified host name) []: [証明書を使用するドメイン名]
Email Address []: [メールアドレス]

次に、生成した秘密鍵から公開鍵を生成します。

OpenSSLを使用して、プライベートキーファイルから公開鍵(.pem形式)を生成します。

openssl rsa -in key.pem -pubout -out public-key.pem

作成したkey.pem・public-key.pemの中身を.envファイルに記載します。以下は例です。

.env

JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
privatekey
-----END RSA PRIVATE KEY-----"
JWT_PUBLIC_KEY=="-----BEGIN RSA PUBLIC KEY-----
publickey
-----END RSA PUBLIC KEY-----"

以上で、LTI連携の実装と準備は完了です。

LTIツールの登録と利用

LMSのコースにLTIツールを登録します。

  1. Moodleに管理者としてログインします。
  2. 「マイコース」タブに表示されるコース一覧から、開始する授業を選択します。
  3. 「編集モード」をONにします。
  4. 「活動またはリソースを追加する」をクリックします。
  5. 外部LTIツール一覧に表示されている「サンプルLTIツール」をクリックします。
  6. 「コンテンツを選択する」をクリックすると、小テストの種類を選択する画面が表示されます。 コンテンツ選択画面
  7. 実施する小テストの種類を選択します。今回は例として小テスト3を選択します。 コンテンツ選択画面2
  8. 決定ボタンをクリックします。 コンテンツ選択画面3
  9. コンテンツ選択画面が閉じたあと、「コンテンツ」の右側に"選択済みコンテンツ"のチェックが入り、活動名に「小テスト」が表示されます。
  10. 「保存してコースに戻る」をクリックし、設定を保存します。

LTIツールの起動

登録したLTIツールを起動します。

  1. Moodleに学生としてログインします。
  2. LTIツールを登録したコースをクリックします。
  3. LTIツール(サンプルLTIツール)をクリックします。
  4. 選択した種類の小テストが表示されます。また、小テストを受けている人、このコースを受けているメンバ一覧が表示されます。 コンテンツ選択画面

ユーザーは、ツールに直接アクセスすることなく、LMSから小テストを表示することができました。
また、ツールは、LTIを通してLMSからユーザー情報を取得し、表示することができました。

おわりに

今回は、NestJSを使用してLTI連携の実装フローを紹介しました。
LTI連携はLMSを利用するユーザーにとって手間を削減できる重要な仕組みとなっていますので、みなさんもぜひ試してみてください。


TOP
アルファロゴ 株式会社アルファシステムズは、ITサービス事業を展開しています。このブログでは、技術的な取り組みを紹介しています。X(旧Twitter)で更新通知をしています。