-
Notifications
You must be signed in to change notification settings - Fork 3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
LTI Deep Linking 対応 #4
Comments
deep link に関する仕様メモ: https://www.imsglobal.org/spec/lti-dl/v2p0 show the form して LMS 側で選択可能な UI を提供する / リンク先側での操作を不要にできることを活かすお話。
|
試してみました: パッチ diff --git a/server/models/ltiResourceLinkRequest.ts b/server/models/ltiResourceLinkRequest.ts
index e8ba2e7c..0328bf6b 100644
--- a/server/models/ltiResourceLinkRequest.ts
+++ b/server/models/ltiResourceLinkRequest.ts
@@ -3,7 +3,6 @@ import type { FromSchema } from "json-schema-to-ts";
export const LtiResourceLinkRequestSchema = {
title: "LTI Resource Link Request",
type: "object",
- required: ["id"],
properties: {
id: { title: "LTI Resource Link ID", type: "string" },
title: { title: "Title", type: "string" },
diff --git a/server/models/session.ts b/server/models/session.ts
index aa14c030..278020fa 100644
--- a/server/models/session.ts
+++ b/server/models/session.ts
@@ -18,7 +18,7 @@ export type SessionSchema = {
ltiVersion: LtiVersionSchema;
ltiUser: LtiUserSchema;
ltiRoles: LtiRolesSchema;
- ltiResourceLinkRequest: LtiResourceLinkRequestSchema;
+ ltiResourceLinkRequest?: LtiResourceLinkRequestSchema;
ltiContext: LtiContextSchema;
ltiLaunchPresentation?: LtiLaunchPresentationSchema;
ltiAgsEndpoint?: LtiAgsEndpointSchema;
@@ -36,7 +36,6 @@ export const sessionSchema = {
"ltiVersion",
"ltiUser",
"ltiRoles",
- "ltiResourceLinkRequest",
"ltiContext",
"user",
"systemSettings",
diff --git a/server/services/init.ts b/server/services/init.ts
index 189392c7..8d6be9f7 100644
--- a/server/services/init.ts
+++ b/server/services/init.ts
@@ -12,15 +12,17 @@ const frontendUrl = `${FRONTEND_ORIGIN}${FRONTEND_PATH}`;
/** 起動時の初期化プロセス */
async function init({ session }: FastifyRequest) {
const systemSettings = getSystemSettings();
- const ltiResourceLink = await findLtiResourceLink({
- consumerId: session.oauthClient.id,
- id: session.ltiResourceLinkRequest.id,
- });
+ const ltiResourceLink = session.ltiResourceLinkRequest?.id
+ ? await findLtiResourceLink({
+ consumerId: session.oauthClient.id,
+ id: session.ltiResourceLinkRequest.id,
+ })
+ : null;
if (ltiResourceLink) {
await upsertLtiResourceLink({
...ltiResourceLink,
- title: session.ltiResourceLinkRequest.title ?? ltiResourceLink.title,
+ title: session.ltiResourceLinkRequest?.title ?? ltiResourceLink.title,
contextTitle: session.ltiContext.title ?? ltiResourceLink.contextTitle,
contextLabel: session.ltiContext.label ?? ltiResourceLink.contextLabel,
});
diff --git a/server/validators/ltiClaims.ts b/server/validators/ltiClaims.ts
index 617ba79a..a8278bb5 100644
--- a/server/validators/ltiClaims.ts
+++ b/server/validators/ltiClaims.ts
@@ -1,5 +1,6 @@
import {
Equals,
+ IsIn,
IsNotEmpty,
IsOptional,
IsString,
@@ -37,8 +38,10 @@ export class LtiClaims {
),
});
}
- @Equals("LtiResourceLinkRequest")
- "https://purl.imsglobal.org/spec/lti/claim/message_type"!: "LtiResourceLinkRequest";
+ @IsIn(["LtiResourceLinkRequest", "LtiDeepLinkingRequest"])
+ "https://purl.imsglobal.org/spec/lti/claim/message_type"!:
+ | "LtiResourceLinkRequest"
+ | "LtiDeepLinkingRequest";
@Equals("1.3.0")
"https://purl.imsglobal.org/spec/lti/claim/version"!: "1.3.0";
@IsNotEmpty()
@@ -46,9 +49,9 @@ export class LtiClaims {
"https://purl.imsglobal.org/spec/lti/claim/deployment_id"!: string;
@IsNotEmpty()
"https://purl.imsglobal.org/spec/lti/claim/target_link_uri"!: string;
- @IsNotEmpty()
+ @IsOptional()
@ValidateNested()
- "https://purl.imsglobal.org/spec/lti/claim/resource_link"!: ResourceLinkClaim;
+ "https://purl.imsglobal.org/spec/lti/claim/resource_link"?: ResourceLinkClaim;
@IsNotEmpty()
@IsString({ each: true })
"https://purl.imsglobal.org/spec/lti/claim/roles"!: string[];
@@ -78,9 +81,9 @@ class ResourceLinkClaim {
constructor(props?: Partial<ResourceLinkClaim>) {
Object.assign(this, props);
}
- @IsNotEmpty()
+ @IsOptional()
@IsString()
- id!: string;
+ id?: string;
@IsOptional()
@IsString()
title?: string; id_token {
"nonce": "OI7cwo57cTe_sz0usHLUk7nL0kYqbKqyBvwDrvgHs8s",
"iat": 1686556178,
"exp": 1686556238,
"iss": "http://localhost:8081",
"aud": "By4PWqdQVnQx7SA",
"https://purl.imsglobal.org/spec/lti/claim/deployment_id": "1",
"https://purl.imsglobal.org/spec/lti/claim/target_link_uri": "http://localhost:8080",
"sub": "2",
"https://purl.imsglobal.org/spec/lti/claim/lis": {
"person_sourcedid": "",
"course_section_sourcedid": ""
},
"https://purl.imsglobal.org/spec/lti/claim/roles": [
"http://purl.imsglobal.org/vocab/lis/v2/institution/person#Administrator",
"http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor",
"http://purl.imsglobal.org/vocab/lis/v2/system/person#Administrator"
],
"https://purl.imsglobal.org/spec/lti/claim/context": {
"id": "2",
"label": "C1",
"title": "コース1",
"type": [
"CourseSection"
]
},
"https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiDeepLinkingRequest",
"https://purl.imsglobal.org/spec/lti/claim/launch_presentation": {
"locale": "en"
},
"https://purl.imsglobal.org/spec/lti/claim/ext": {
"lms": "moodle-2"
},
"https://purl.imsglobal.org/spec/lti/claim/tool_platform": {
"product_family_code": "moodle",
"version": "2022112802",
"guid": "e616f7a43757ade063281213a522f1f6",
"name": "New Site",
"description": "New Site"
},
"https://purl.imsglobal.org/spec/lti/claim/version": "1.3.0",
"https://purl.imsglobal.org/spec/lti-ags/claim/endpoint": {
"scope": [
"https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
"https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly",
"https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly",
"https://purl.imsglobal.org/spec/lti-ags/scope/score"
],
"lineitems": "http://localhost:8081/mod/lti/services.php/2/lineitems?type_id=1"
},
"https://purl.imsglobal.org/spec/lti/claim/custom": {
"context_memberships_url": "http://localhost:8081/mod/lti/services.php/CourseSection/2/bindings/1/memberships"
},
"https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice": {
"context_memberships_url": "http://localhost:8081/mod/lti/services.php/CourseSection/2/bindings/1/memberships",
"service_versions": [
"1.0",
"2.0"
]
},
"https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings": {
"accept_types": [
"ltiResourceLink"
],
"accept_presentation_document_targets": [
"frame",
"iframe",
"window"
],
"accept_copy_advice": false,
"accept_multiple": true,
"accept_unsigned": false,
"auto_create": false,
"can_confirm": false,
"deep_link_return_url": "http://localhost:8081/mod/lti/contentitem_return.php?course=2&id=1&sesskey=tthwwUyAfa",
"title": "リンク",
"text": ""
}
} LtiResourceLinkRequest との差分:
決まっていない点
|
LtiDeepLinkingResponse の中で "https://purl.imsglobal.org/spec/lti-dl/claim/content_items" に "custom" プロパティを加える。 例: client.requestObject などによって JWT を生成
抜粋 "https://purl.imsglobal.org/spec/lti-dl/claim/content_items": [
{
"type": "ltiResourceLink",
"title": "Ltijs Demo",
"custom": {
"name": "Resource2",
"value": "value2"
}
}
], LtiResourceLinkRequest
別の案 同じドメインであれば "url" を指定してツールのログイン初期化エンドポイントにクエリーを足すなど別のURLを指定することができる。 |
LTI-AGS 2.0 と組み合わせることで可能かどうか調べてみた。
https://www.imsglobal.org/spec/lti-ags/v2p0/openapi/#/default/LineItem.GET あらかじめ "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly" を許可することで LineItem 取得エンドポイントを介して Resource Link ID の取得が可能なようにも読めた。なので、実際にMoodleで試してみた。 Deep Linking での id_token {
"nonce": "kAB0PGujNiBM56k7yCoKyHfxUbKerkyEKBVcXjFn5ro",
"iat": 1686651087,
"exp": 1686651147,
"iss": "http://localhost:8081",
"aud": "By4PWqdQVnQx7SA",
"https://purl.imsglobal.org/spec/lti/claim/deployment_id": "1",
"https://purl.imsglobal.org/spec/lti/claim/target_link_uri": "http://localhost:8080",
"sub": "2",
"https://purl.imsglobal.org/spec/lti/claim/lis": {
"person_sourcedid": "",
"course_section_sourcedid": ""
},
"https://purl.imsglobal.org/spec/lti/claim/roles": [
"http://purl.imsglobal.org/vocab/lis/v2/institution/person#Administrator",
"http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor",
"http://purl.imsglobal.org/vocab/lis/v2/system/person#Administrator"
],
"https://purl.imsglobal.org/spec/lti/claim/context": {
"id": "2",
"label": "C1",
"title": "コース1",
"type": [
"CourseSection"
]
},
"https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiDeepLinkingRequest",
"https://purl.imsglobal.org/spec/lti/claim/launch_presentation": {
"locale": "en"
},
"https://purl.imsglobal.org/spec/lti/claim/ext": {
"lms": "moodle-2"
},
"https://purl.imsglobal.org/spec/lti/claim/tool_platform": {
"product_family_code": "moodle",
"version": "2022112802",
"guid": "e616f7a43757ade063281213a522f1f6",
"name": "New Site",
"description": "New Site"
},
"https://purl.imsglobal.org/spec/lti/claim/version": "1.3.0",
"https://purl.imsglobal.org/spec/lti-ags/claim/endpoint": {
"scope": [
"https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
"https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly",
"https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly",
"https://purl.imsglobal.org/spec/lti-ags/scope/score"
],
"lineitems": "http://localhost:8081/mod/lti/services.php/2/lineitems?type_id=1"
},
"https://purl.imsglobal.org/spec/lti/claim/custom": {
"context_memberships_url": "http://localhost:8081/mod/lti/services.php/CourseSection/2/bindings/1/memberships"
},
"https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice": {
"context_memberships_url": "http://localhost:8081/mod/lti/services.php/CourseSection/2/bindings/1/memberships",
"service_versions": [
"1.0",
"2.0"
]
},
"https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings": {
"accept_types": [
"ltiResourceLink"
],
"accept_presentation_document_targets": [
"frame",
"iframe",
"window"
],
"accept_copy_advice": false,
"accept_multiple": true,
"accept_unsigned": false,
"auto_create": false,
"can_confirm": false,
"deep_link_return_url": "http://localhost:8081/mod/lti/contentitem_return.php?course=2&id=1&sesskey=q3BpOMrSgL",
"title": "chibichilo",
"text": ""
}
} |
まずはこの方針で進めるのがよさそうです。 |
いくつかの機能の提案についてはIssueを分割しました (本件とは別スコープ) |
LtiDeepLinkingResponceの一部:
LtiResourceLinkRequest id_token
これを、セッションに書き込み→このURLにクライアントがアクセスする |
LtiDeepLinkingResponce Tool-Originating Messages によって行なう(と想像)。 JWTのclaimによって身元や有効期限が表明され認証を行なう。仕様を確認してみる。
exp の部分の参考値を調べてみる。 expiresIn: 60 とあり、有効期間60秒(定数)だと分かった。usually no more than a few minutes としてこのくらいの値が妥当なのかな。 実験してみます。 |
実験方法
実証用パッチ: diff --git a/pages/books/index.tsx b/pages/books/index.tsx
index aed7c0e7..c47254fa 100644
--- a/pages/books/index.tsx
+++ b/pages/books/index.tsx
@@ -83,6 +83,8 @@ function Index() {
return (
<>
+ {/* TODO: 実験目的。後で削除 */}
+ <a href="http://localhost:8080/api/v2/lti/demo">/api/v2/lti/demo</a>
<Books linkedBook={linkedBook} {...handlers} />
{previewContent?.type === "book" && (
<BookPreviewDialog {...dialogProps} book={previewContent}>
diff --git a/server/config/app.ts b/server/config/app.ts
index 01cb54c0..8093ef97 100644
--- a/server/config/app.ts
+++ b/server/config/app.ts
@@ -35,17 +35,18 @@ async function app(fastify: FastifyInstance, options: Options) {
routePrefix: `${basePath}/swagger`,
});
- await fastify.register(helmet, {
- contentSecurityPolicy: {
- directives: {
- defaultSrc: ["'self'"],
- imgSrc: ["'self'", "data:"],
- scriptSrc: ["'self'"].concat(fastify.swaggerCSP.script),
- styleSrc: ["'self'"].concat(fastify.swaggerCSP.style),
- },
- },
- frameguard: false,
- });
+ // TODO: デバッグ目的。本番環境では必須。あとで戻す。
+ // await fastify.register(helmet, {
+ // contentSecurityPolicy: {
+ // directives: {
+ // defaultSrc: ["'self'"],
+ // imgSrc: ["'self'", "data:"],
+ // scriptSrc: ["'self'"].concat(fastify.swaggerCSP.script),
+ // styleSrc: ["'self'"].concat(fastify.swaggerCSP.style),
+ // },
+ // },
+ // frameguard: false,
+ // });
await Promise.all([
fastify.register(cors, {
diff --git a/server/config/routes/lti.ts b/server/config/routes/lti.ts
index 2fcc2ede..4352281f 100644
--- a/server/config/routes/lti.ts
+++ b/server/config/routes/lti.ts
@@ -9,6 +9,10 @@ import * as ltiKeys from "$server/services/ltiKeys";
import * as ltiClients from "$server/services/ltiClients";
import * as linkSearch from "$server/services/linkSearch";
import * as ltiMembersService from "$server/services/ltiMembers";
+import { SignJWT, importJWK } from "jose";
+import { generators } from "openid-client";
+import { createPrivateKey } from "$server/utils/ltiv1p3/jwk";
+import findClient from "$server/utils/ltiv1p3/findClient";
export async function launch(fastify: FastifyInstance) {
const path = "/lti/launch";
@@ -98,3 +102,54 @@ export async function ltiMembers(fastify: FastifyInstance) {
Body: ltiMembersService.Body;
}>(path, { schema: method.put, ...hooks.put }, handler(update));
}
+
+export async function demo(fastify: FastifyInstance) {
+ fastify.get("/lti/demo", async (req, res) => {
+ // TODO: session には LtiDeepLinkingSettings がないけど少なくとも deepLinkReturnUrl を参照できるようにしておきたい
+ // TODO: session には LtiDeploymentId がないけど必要
+ // TODO: 「LTI Resource Linkの更新」ではなくDL更新処理にしてみる実験
+ // ホントは別のAPIに分けるか実装に合わせて説明を変更するなどしておくべき
+ const url =
+ "http://localhost:8081/mod/lti/contentitem_return.php?course=2&id=1&sesskey=99IpUHWa4o";
+ const alg = "RS256";
+ const privateKey = await createPrivateKey();
+ const client = await findClient(req.session.oauthClient.id);
+ const jwt = await new SignJWT({
+ nonce: generators.nonce(),
+ "https://purl.imsglobal.org/spec/lti/claim/deployment_id": "1", // ← 例。ホントはセッションから取り出したい
+ "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/msg":
+ "Successfully Registered",
+ "https://purl.imsglobal.org/spec/lti-dl/claim/content_items": [
+ {
+ type: "ltiResourceLink",
+ url: "http://localhost:8080/book?bookId=99999", // ← 例
+ },
+ ],
+ })
+ .setProtectedHeader({ alg, kid: privateKey?.kid })
+ // @ts-expect-error TODO: undefined であればエラーを返す
+ .setIssuer(client.metadata.client_id)
+ // @ts-expect-error TODO: undefined であればエラーを返す
+ .setAudience(client.issuer.metadata.issuer)
+ .setIssuedAt()
+ .setExpirationTime("60s")
+ .sign(await importJWK({ alg, ...privateKey }));
+
+ console.log(jwt);
+
+ void res.header("content-type", "text/html; charset=utf-8");
+ await res.send(
+ `
+<form style="display: none;" action="${url}" method="POST">
+<input type="hidden" name="JWT" value="${jwt}" />
+</form>
+<script>
+document.querySelector("form").submit()
+</script>
+`
+ );
+ });
+}
diff --git a/server/services/ltiCallback.ts b/server/services/ltiCallback.ts
index 5ac17125..ef659d15 100644
--- a/server/services/ltiCallback.ts
+++ b/server/services/ltiCallback.ts
@@ -44,6 +44,22 @@ export async function post(req: FastifyRequest<{ Body: Props }>) {
nonce: req.session.oauthClient.nonce,
});
const claims = token.claims();
+
+ // TODO: 実験目的。後で削除
+ console.debug(
+ "url",
+ claims[
+ "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings"
+ // @ts-expect-error TODO: LtiClaimsの他のクレームと同様にバリデーションを行なうべき
+ ]?.deep_link_return_url
+ );
+
+ // TODO: 実験目的。後で削除
+ console.debug(
+ "deploymentId",
+ claims["https://purl.imsglobal.org/spec/lti/claim/deployment_id"]
+ );
+
const ltiClaims = new LtiClaims(claims as Partial<LtiClaims>);
await validateOrReject(ltiClaims);
const session: Omit<SessionSchema, "user" | "systemSettings"> = { 実験結果 Screencast.from.2023.06.20.20.02.21.webm分かったこと
|
@YouheiNozaki |
本件は分割・分担したほうが良さそうな気もしました。TODOの部分でそれぞれ課題の分割してそれぞれ個別のIssuesとして起票してもらうことできますか? いくつか片付けるのを私も手伝わせてください。よろしくです 🙏 > @YouheiNozaki さん |
ありがとうございます! そうですね。正直認証周りの仕様読み解くの結構難しいです。。。
いただいたデモをベースにタスク分けや問題の切り分けしたりはできると思うので、後ほど対応します🙏 どちらにしろ、少し実装相談等、お話したいので来週頭あたりにミーティングお願いしたいです🙏 |
こちらタスク分割しました! |
はい、大丈夫です 🙆♂️ |
すいません、明日はちょっと予定があるので、来週頭にセットさせていただきます🙏 |
メモ: 実装のゴール
進め方
|
この2つのコメントについて確認のほどよろしくお願いします 🙏 |
LMS からは現在マイクロコンテンツへのリンクとして事前に登録された学習コンテンツにジャンプするしかナビゲーションが存在しないが、昨年標準化された LTI DL を使っていくことで権限や操作内容に応じたアクセスポイントの切替などナビゲーションの改善が出来るハズ。
https://www.imsglobal.org/spec/lti-dl/v2p0
まだ仕様を読めておらず、現状何が出来なくてどう変えることが出来るのかという詳細を把握しきれていないが、ひとまず将来やりたいこととして enhancement issue に記録
TODO:
The text was updated successfully, but these errors were encountered: