npocccties / chibichilo

CHiBi-CHiLOは,インターネットに散在するビデオを,トピック単位で整理,管理し,組み合わせて,複数のLMSにビデオ教材として提供するオンライン学習支援ツールです.
https://npocccties.github.io/chibichilo/
MIT License
6 stars 3 forks source link

LTI Deep Linking 対応 #4

Closed cccties closed 1 year ago

cccties commented 4 years ago

LMS からは現在マイクロコンテンツへのリンクとして事前に登録された学習コンテンツにジャンプするしかナビゲーションが存在しないが、昨年標準化された LTI DL を使っていくことで権限や操作内容に応じたアクセスポイントの切替などナビゲーションの改善が出来るハズ。

https://www.imsglobal.org/spec/lti-dl/v2p0

まだ仕様を読めておらず、現状何が出来なくてどう変えることが出来るのかという詳細を把握しきれていないが、ひとまず将来やりたいこととして enhancement issue に記録


TODO:

dynamis commented 1 year ago

deep link に関する仕様メモ:

https://www.ssken.gr.jp/MAINSITE/event/2019/20191024-edu/lecture-02/SSKEN_edu2019_TokiwaYuji_presentation_20191015.pdf

https://www.imsglobal.org/spec/lti-dl/v2p0 image

show the form して LMS 側で選択可能な UI を提供する / リンク先側での操作を不要にできることを活かすお話。

Deep linking with LTI 1.3

Deep Linking 1.3

  • Users create content selectors with ad-hoc links
  • Instead of a basic launch, users can create a link selector for either Quicklink or Insert Stuff (addition extensions planned)
  • All auth and security data is maintained by the tool's deployment
  • Content selectors continue to be accessed the save way plugins have been in the past
  • LTI file picker for OneDrive users that can be used as a Quicklink to a file
kou029w commented 1 year ago

試してみました:

パッチ

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 との差分:


決まっていない点

kou029w commented 1 year ago
  • それを使って LtiResourceLinkRequest として受け取れるのかな?

LtiDeepLinkingResponse の中で "https://purl.imsglobal.org/spec/lti-dl/claim/content_items" に "custom" プロパティを加える。 "https://purl.imsglobal.org/spec/lti/claim/custom" で受け取れる。

例: LtiDeepLinkingResponse

client.requestObject などによって JWT を生成 リクエストボディに JWT={生成したJWT … 署名したLtiDeepLinkingResponse} を指定 "deep_link_return_url" に content-type:application/x-www-form-urlencoded で POST すること

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImYwMTU1NjBhYWZiNThmYThjOGMzOGM5N2FmMGJiZDYzIn0.eyJpc3MiOiJNbkxRV3Q2czNLZ2NHMWoiLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjgwODEiLCJub25jZSI6Im9ibTE3YXM5aTcxM3VjNzQ1bzcxdGlxcjMiLCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS9kZXBsb3ltZW50X2lkIjoiMiIsImh0dHBzOi8vcHVybC5pbXNnbG9iYWwub3JnL3NwZWMvbHRpL2NsYWltL21lc3NhZ2VfdHlwZSI6Ikx0aURlZXBMaW5raW5nUmVzcG9uc2UiLCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS92ZXJzaW9uIjoiMS4zLjAiLCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS1kbC9jbGFpbS9tc2ciOiJTdWNjZXNzZnVsbHkgUmVnaXN0ZXJlZCIsImh0dHBzOi8vcHVybC5pbXNnbG9iYWwub3JnL3NwZWMvbHRpLWRsL2NsYWltL2NvbnRlbnRfaXRlbXMiOlt7InR5cGUiOiJsdGlSZXNvdXJjZUxpbmsiLCJ0aXRsZSI6Ikx0aWpzIERlbW8iLCJjdXN0b20iOnsibmFtZSI6IlJlc291cmNlMiIsInZhbHVlIjoidmFsdWUyIn19XSwiaWF0IjoxNjg2NTY2NDE4LCJleHAiOjE2ODY1NjY0Nzh9.iILK-ZyG63vtaCOZj8MV-jSb-Hl2XGSDPHbFn2001IVrA6ZJexSvcG5pPWjZUbfTsJl1PkzBE4g65Rut72E6fjMbLxON3t-ghXmpdM1v_CohSBamksESlRuidzy12qOOz7xHdqosaiDV2YmRLbMRf97LWN_HAHtOFb80F5KK1mRMSSHdekvOt4FUYTUeVLfaQBC1eComDY2kFPLThbfiR--g1T50UYPCPxJa-pynwwW2s2jh1xV_Q9_cNwvC0qWtNJf4QeSIQO2uaZUg9HS0jgMGrQEVnU-L-3IRUF6Uv9uyHeOQmh3iPtXZZDSVB37sQWGzTRNdHiIsec_keF3QfJO8HdmOLRhJdRm_XBrE5FRLCKWuM3qKJL-ZFZ3oOH_-LesPBZ1OT7HQXx6u9OxUIlcilJeq1j3rdGBeQaBSrDHg7jR7kaoaXkr6uJ9JvymqYrXFshrxq8j7qi5qvNwfzwQ05a9UKil0-gHDxzsiCEUYv5mPQYRrfm_WiAYtKTfbC-QVPFXgn4LjyGQ-2C0aUYuWDTWn3XV5h1jaQJhlt74-dtKs_se4l05ZUc5eLqGZfrDFY_ig_tVGF1qcvr0HtQTL-JJWupUBDZKKuIQwfM5whN7aVT8rwbZizTGkUl1qw2uT2jXA1cmfkUygOF56oeI9S3VpOoHtsa4gykZDFvc

抜粋

  "https://purl.imsglobal.org/spec/lti-dl/claim/content_items": [
    {
      "type": "ltiResourceLink",
      "title": "Ltijs Demo",
      "custom": {
        "name": "Resource2",
        "value": "value2"
      }
    }
  ],

LtiResourceLinkRequest

eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ijg3YzdiZDE5MzBjZWM1ODczNTFiIn0.eyJub25jZSI6Im1tZHViZXVscmNvNzZkZHRscHhhcDNmM3ciLCJpYXQiOjE2ODY1NjY0MjAsImV4cCI6MTY4NjU2NjQ4MCwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgxIiwiYXVkIjoiTW5MUVd0NnMzS2djRzFqIiwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGkvY2xhaW0vZGVwbG95bWVudF9pZCI6IjIiLCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS90YXJnZXRfbGlua191cmkiOiJodHRwczovL3Bhc3NpbmctbG9va3NtYXJ0LWdvdmVybmluZy12aWN0b3JpYW4udHJ5Y2xvdWRmbGFyZS5jb20iLCJzdWIiOiIyIiwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGkvY2xhaW0vbGlzIjp7InBlcnNvbl9zb3VyY2VkaWQiOiIiLCJjb3Vyc2Vfc2VjdGlvbl9zb3VyY2VkaWQiOiIifSwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGkvY2xhaW0vcm9sZXMiOlsiaHR0cDovL3B1cmwuaW1zZ2xvYmFsLm9yZy92b2NhYi9saXMvdjIvaW5zdGl0dXRpb24vcGVyc29uI0FkbWluaXN0cmF0b3IiLCJodHRwOi8vcHVybC5pbXNnbG9iYWwub3JnL3ZvY2FiL2xpcy92Mi9tZW1iZXJzaGlwI0luc3RydWN0b3IiLCJodHRwOi8vcHVybC5pbXNnbG9iYWwub3JnL3ZvY2FiL2xpcy92Mi9zeXN0ZW0vcGVyc29uI0FkbWluaXN0cmF0b3IiXSwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGkvY2xhaW0vY29udGV4dCI6eyJpZCI6IjIiLCJsYWJlbCI6IkMxIiwidGl0bGUiOiJcdTMwYjNcdTMwZmNcdTMwYjkxIiwidHlwZSI6WyJDb3Vyc2VTZWN0aW9uIl19LCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS9tZXNzYWdlX3R5cGUiOiJMdGlSZXNvdXJjZUxpbmtSZXF1ZXN0IiwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGkvY2xhaW0vcmVzb3VyY2VfbGluayI6eyJ0aXRsZSI6Ikx0aWpzIERlbW8iLCJkZXNjcmlwdGlvbiI6IiIsImlkIjoiMiJ9LCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS9sYXVuY2hfcHJlc2VudGF0aW9uIjp7ImxvY2FsZSI6ImVuIiwiZG9jdW1lbnRfdGFyZ2V0IjoiZnJhbWUiLCJyZXR1cm5fdXJsIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgxL21vZC9sdGkvcmV0dXJuLnBocD9jb3Vyc2U9MiZsYXVuY2hfY29udGFpbmVyPTUmaW5zdGFuY2VpZD0yJnNlc3NrZXk9MkMzSGFFazhZViJ9LCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS9leHQiOnsibG1zIjoibW9vZGxlLTIifSwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGkvY2xhaW0vdG9vbF9wbGF0Zm9ybSI6eyJwcm9kdWN0X2ZhbWlseV9jb2RlIjoibW9vZGxlIiwidmVyc2lvbiI6IjIwMjIxMTI4MDIiLCJndWlkIjoiZTYxNmY3YTQzNzU3YWRlMDYzMjgxMjEzYTUyMmYxZjYiLCJuYW1lIjoiTmV3IFNpdGUiLCJkZXNjcmlwdGlvbiI6Ik5ldyBTaXRlIn0sImh0dHBzOi8vcHVybC5pbXNnbG9iYWwub3JnL3NwZWMvbHRpL2NsYWltL3ZlcnNpb24iOiIxLjMuMCIsImh0dHBzOi8vcHVybC5pbXNnbG9iYWwub3JnL3NwZWMvbHRpL2NsYWltL2N1c3RvbSI6eyJuYW1lIjoiUmVzb3VyY2UyIiwidmFsdWUiOiJ2YWx1ZTIifX0.VYK5sfav63k_rxzfk3EPPc8GoF5TvAXbhlezP-XQ8Gz-c5HFtdoz3_4fXQV2jdopfww4R32XzJx8BetmBYwyTzGty9DP3SbmbFbk2eb_bHOoLc2d6uQAjfeST1CWbL9_DydNws2LpvMnxy1d0vzYUA06-eXrlIXitIPtiMAx9smQZVaCQWrQ_lZS8h8o68FHKzHNIgvvHJ2kSxoVsXa2t7BZtIvFN5nZMpzLQSD2uKX49bbwJzOYEYwQBzvQJG3FbNWf9wShg0IXERgPEp7Kb3JNlHEcWIeRBw7APJ78MoDc7Hx1A5rkQBYz1p_DBgp6hjCPN9_rygIwNXbX3OunHQ

別の案

同じドメインであれば "url" を指定してツールのログイン初期化エンドポイントにクエリーを足すなど別のURLを指定することができる。 ここが指定可能。 image

kou029w commented 1 year ago
  • 作成するResource Link IDを指定する手段があるかは要調査

LTI-AGS 2.0 と組み合わせることで可能かどうか調べてみた。 → 不可能

3.2.8 resourceLinkId and binding a line item to a resource link A line item MAY be attached to a resource link by including a 'resourceLinkId' in the payload. The resource link MUST exist in the context where the line item is created, and MUST be a link owned by the same tool. If not, the line item creation MUST fail with a response code of Not Found 404.

The platform MAY remove the line items attached to a resource link if the resource link itself is removed.

https://www.imsglobal.org/spec/lti-ags/v2p0#resourcelinkid-and-binding-a-line-item-to-a-resource-link

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で試してみた。 → LineItem 取得エンドポイントは LtiDeepLinkingRequest には存在せず。

Deep Linking での id_token ```json { "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": "" } } ```
kou029w commented 1 year ago
  • Deep Linkingによって選択後、提供するブックを選択・変更する場合、どう扱うか?
    • A. Deep Linkingを優先する・Deep Linking以外でのリンクの変更を禁止
    • この場合、リンクを変更するには Deep Linking で再び選択するか、URL or カスタムパラメータを手動で変更 or 削除して選択しなおす

まずはこの方針で進めるのがよさそうです。 なお、もし仮にDeep Linkingが有効化されていない・選択していないケース(→ URL or カスタムパラメータが設定されていないケース)ならば、従来どおりリンクからアクセスして操作できることを期待 (後方互換の観点)。

kou029w commented 1 year ago

いくつかの機能の提案についてはIssueを分割しました (本件とは別スコープ)

kou029w commented 1 year ago

LtiDeepLinkingResponceの一部:

  "https://purl.imsglobal.org/spec/lti-dl/claim/content_items": [
    {
      "type": "ltiResourceLink",
      "url": ここで指定したURLがLtiResourceLinkRequestのクレームにあるtarget_link_uriとして設定されるハズ
    }
  ],

LtiResourceLinkRequest

id_token

  "https://purl.imsglobal.org/spec/lti/claim/target_link_uri": "http://localhost:8080/book?bookId=1",

これを、セッションに書き込み→このURLにクライアントがアクセスする

kou029w commented 1 year ago

LtiDeepLinkingResponce

Tool-Originating Messages によって行なう(と想像)。 https://www.imsglobal.org/spec/security/v1p0/#tool-originating-messages

JWTのclaimによって身元や有効期限が表明され認証を行なう。仕様を確認してみる。

iss : REQUIRED. Issuer Identifier for the Issuer of the message i.e. the Tool. It must be the OAuth 2.0 client_id of the Tool (this MAY be provided to it by the Platform upon registration of the Tool).

aud : REQUIRED. Audience(s) for whom this Tool JWT is intended. It MUST contain the case-sensitive URL used by the Platform to identify itself as an Issuer in platform-originating Messages. In the common special case when there is one audience, the aud value MAY be a single case-sensitive string.

exp : REQUIRED. Expiration time on or after which the Platform MUST NOT accept the Tool JWT for processing. When processing this parameter, the Platform MUST verify that the time expressed in this Claim occurs after the current date/time. Implementers MAY provide for some small leeway, usually no more than a few minutes, to account for clock skew. This Claim's value MUST be a JSON number representing the number of seconds offset from 1970-01-01T00:00:00Z (UTC). See [RFC3339] for details regarding date/times in general and UTC in particular.

iat : REQUIRED. Time at which the Issuer generated the Tool JWT. Its value is a JSON number representing the number of seconds offset from 1970-01-01T00:00:00Z (UTC) until the generation time.

nonce : REQUIRED. String value used to associate a Tool session with a Tool JWT, and to mitigate replay attacks. The nonce value is a case-sensitive string.

azp : OPTIONAL. Authorized party - the party to which the Tool JWT was issued. If present, it MUST contain the same value as in the aud Claim. The azp value is a case-sensitive string containing a String or URI value.

exp の部分の参考値を調べてみる。 Ltijsの例:

https://github.com/Cvmcosta/ltijs/blob/6a7bcc622c0123a3dd70e95a884deb12409a0ad8/src/Provider/Services/DeepLinking.js#L113

expiresIn: 60 とあり、有効期間60秒(定数)だと分かった。usually no more than a few minutes としてこのくらいの値が妥当なのかな。

実験してみます。

kou029w commented 1 year ago

実験方法

実証用パッチ:

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

分かったこと

kou029w commented 1 year ago

@YouheiNozaki LtiDeepLinkingResponse や Tool-Originating Messages の仕組み理解して実装方針決めてコードを書いていくことができそうでしょうか? 難しい or 一部説明があればできそう or 問題ない/実装中 など、フィードバックもらえればそれに合わせます。 意外と難しいのかもなのでそのあたりはお気軽にご相談ください :pray:

kou029w commented 1 year ago

意外と難しいのかも

本件は分割・分担したほうが良さそうな気もしました。TODOの部分でそれぞれ課題の分割してそれぞれ個別のIssuesとして起票してもらうことできますか? いくつか片付けるのを私も手伝わせてください。よろしくです :pray: > @YouheiNozaki さん

YouheiNozaki commented 1 year ago

ありがとうございます!

そうですね。正直認証周りの仕様読み解くの結構難しいです。。。

TODOの部分でそれぞれ課題の分割してそれぞれ個別のIssuesとして起票してもらうことできますか?

いただいたデモをベースにタスク分けや問題の切り分けしたりはできると思うので、後ほど対応します🙏

どちらにしろ、少し実装相談等、お話したいので来週頭あたりにミーティングお願いしたいです🙏 土日にできる範囲で実装進められるところはやっていこうかと思います。

YouheiNozaki commented 1 year ago

こちらタスク分割しました!

kou029w commented 1 year ago

ミーティングお願いしたいです

はい、大丈夫です :ok_man: いつにしましょうかね?

YouheiNozaki commented 1 year ago

すいません、明日はちょっと予定があるので、来週頭にセットさせていただきます🙏 よろしくお願い致します!

YouheiNozaki commented 1 year ago

メモ:

実装のゴール

進め方

kou029w commented 1 year ago

@YouheiNozaki

https://github.com/npocccties/chibichilo/pull/980#issuecomment-1711109566 https://github.com/npocccties/chibichilo/pull/980#issuecomment-1711383651

この2つのコメントについて確認のほどよろしくお願いします :pray: