1+ const jose = require ( 'node-jose' )
2+ const jwt = require ( 'jsonwebtoken' )
13const router = require ( 'express' ) . Router ( ) //eslint-disable-line new-cap
2- const logger = require ( 'obojobo-express/server/logger' )
3- const uuid = require ( 'uuid' ) . v4
4- const bodyParser = require ( 'body-parser' )
54const oboEvents = require ( 'obojobo-express/server/obo_events' )
6- const Visit = require ( 'obojobo-express/server/models/visit' )
75const Draft = require ( 'obojobo-express/server/models/draft' )
86const config = require ( 'obojobo-express/server/config' ) . materiaLti
9- const {
10- widgetLaunchParams,
11- contentSelectionParams,
12- signLtiParams,
13- verifyScorePassback,
14- expandLisResultSourcedId,
15- createPassbackResult,
16- getValuesFromPassbackXML
17- } = require ( './route-helpers' )
187const materiaEvent = require ( './materia-event' )
198const {
209 requireCurrentUser,
@@ -29,6 +18,9 @@ oboEvents.on('EDITOR_SETTINGS', event => {
2918 }
3019} )
3120
21+ const base64encode = str => Buffer . from ( str ) . toString ( 'base64' )
22+ const base64decode = str => Buffer . from ( str , 'base64' ) . toString ( )
23+
3224// util to get a baseUrl to build urls for for this server
3325// option `isForServerRequest` indicates the url will be
3426// used by materia server to communicate with obo server
@@ -39,75 +31,24 @@ const baseUrl = (req, isForServerRequest = true) => {
3931 return `${ req . protocol } ://${ req . get ( 'host' ) } `
4032}
4133
34+ // util to get a csrf token from an existing request
35+ // feels like a bit of a hack but seems to work
36+ const csrfCookie = req => {
37+ const cookies = req . headers . cookie . split ( ';' )
38+ let cookieCSRFToken = cookies . find ( cookie => cookie . includes ( 'csrftoken' ) )
39+ if ( cookieCSRFToken ) {
40+ cookieCSRFToken = cookieCSRFToken . split ( '=' ) [ 1 ]
41+ }
42+ return cookieCSRFToken
43+ }
44+
4245const renderError = ( res , title , message ) => {
4346 res . set ( 'Content-Type' , 'text/html' )
4447 res . send (
4548 `<html><head><title>Error - ${ title } </title></head><body><h1>${ title } </h1><p>${ message } </p></body></html>`
4649 )
4750}
4851
49- // constructs a signed lti request, renders on client and has the client submit it
50- const renderLtiLaunch = ( paramsIn , method , endpoint , res ) => {
51- const params = signLtiParams ( paramsIn , method , endpoint )
52- const keys = Object . keys ( params )
53- const htmlInput = keys
54- . map ( key => `<input type="hidden" name="${ key } " value="${ params [ key ] } "/>` )
55- . join ( '' )
56-
57- res . set ( 'Content-Type' , 'text/html' )
58- res . send ( `<html>
59- <body>
60- <form id="form" method="${ method } " action="${ endpoint } " >${ htmlInput } </form>
61- <script>document.getElementById('form').submit()</script>
62- </body></html>` )
63- }
64-
65- // receives scores passed back from Materia
66- router
67- . route ( '/materia-lti-score-passback' )
68- . post ( bodyParser . text ( { type : '*/*' } ) )
69- . post ( async ( req , res ) => {
70- let success
71- let visit = { }
72- let passBackData = { }
73- let sourcedIdData = { }
74- const messageId = uuid ( )
75-
76- try {
77- const verified = verifyScorePassback ( req . headers , req . body , req . originalUrl , baseUrl ( req ) )
78- if ( ! verified ) throw Error ( 'Signature verification failed' )
79- passBackData = await getValuesFromPassbackXML ( req . body )
80- sourcedIdData = expandLisResultSourcedId ( passBackData . sourcedId )
81- visit = await Visit . fetchById ( sourcedIdData . visitId )
82- success = true
83- } catch ( e ) {
84- logger . error ( e )
85- success = false
86- }
87-
88- await materiaEvent . insertLtiScorePassbackEvent ( {
89- userId : visit . user_id ,
90- draftId : visit . draft_id ,
91- contentId : visit . draft_content_id ,
92- resourceLinkId : sourcedIdData . nodeId ,
93- messageRefId : passBackData . messageId ,
94- lisResultSourcedId : passBackData . sourcedId ,
95- messageId,
96- success,
97- ip : req . ip ,
98- score : passBackData . score ,
99- materiaHost : config . clientMateriaHost ,
100- isPreview : visit . is_preview ,
101- visitId : sourcedIdData . visitId
102- } )
103-
104- const xml = createPassbackResult ( { success, messageId, messageRefId : passBackData . messageId } )
105-
106- res . status ( success ? 200 : 500 )
107- res . type ( 'application/xml' )
108- res . send ( xml )
109- } )
110-
11152// route to launch a materia widget
11253// the viewer component sends the widget url
11354// to this url and we build a page with all the params
@@ -120,7 +61,6 @@ router
12061 // use the visitId to get the src from the materia chunk
12162 const currentDocument = await req . currentVisit . draftDocument
12263 const materiaNode = currentDocument . getChildNodeById ( req . query . nodeId )
123- const method = 'POST'
12464
12565 if ( ! materiaNode ) {
12666 renderError (
@@ -132,49 +72,60 @@ router
13272 }
13373
13474 const materiaOboNodeId = materiaNode . node . id
135- const endpoint = materiaNode . node . content . src
75+ const widgetEndpoint = materiaNode . node . content . src
13676
13777 // verify the endpoint is the configured materia server
138- if ( ! endpoint . startsWith ( config . clientMateriaHost ) ) {
78+ if ( ! widgetEndpoint . startsWith ( config . clientMateriaHost ) ) {
13979 renderError (
14080 res ,
14181 'Materia Widget Url Restricted' ,
142- `The widget url ${ endpoint } does not match the configured Materia server located at ${ config . clientMateriaHost } .`
82+ `The widget url ${ widgetEndpoint } does not match the configured Materia server located at ${ config . clientMateriaHost } .`
14383 )
14484 return
14585 }
14686
147- const launchParams = widgetLaunchParams (
148- currentDocument ,
149- req . currentVisit ,
150- req . currentUser ,
151- materiaOboNodeId ,
152- baseUrl ( req )
153- )
154-
15587 await materiaEvent . insertLtiLaunchWidgetEvent ( {
15688 userId : req . currentUser . id ,
15789 draftId : currentDocument . draftId ,
15890 contentId : currentDocument . contentId ,
15991 visitId : req . currentVisit . id ,
16092 isPreview : req . currentVisit . is_preview ,
161- lisResultSourcedId : launchParams . lis_result_sourcedid ,
162- resourceLinkId : launchParams . resource_link_id ,
163- endpoint ,
93+ lisResultSourcedId : ` ${ req . currentVisit . id } __ ${ materiaOboNodeId } ` ,
94+ resourceLinkId : ` ${ req . currentVisit . resource_link_id } __ ${ req . currentVisit . draft_id } __ ${ materiaOboNodeId } ` ,
95+ widgetEndpoint ,
16496 ip : req . ip
16597 } )
166- renderLtiLaunch ( launchParams , method , endpoint , res )
98+ const endpoint = `${ config . clientMateriaHost } /ltilaunch/`
99+
100+ const loginHintObj = {
101+ nodeId : materiaOboNodeId ,
102+ widgetEndpoint
103+ }
104+ const loginHint = base64encode ( JSON . stringify ( loginHintObj ) )
105+ const ltiMessageHint = 'resource'
106+ res . redirect (
107+ `${ config . clientMateriaHost } /init/${ config . oboLtiUuid } /?iss=${ baseUrl ( req ) } &client_id=${
108+ config . oboLtiClientId
109+ } &target_link_uri=${ endpoint } &login_hint=${ loginHint } <i_message_hint=${ ltiMessageHint } `
110+ )
167111 } )
168112
169- router . route ( '/materia-lti-picker-return' ) . all ( ( req , res ) => {
113+ router . route ( '/materia-lti-picker-return' ) . post ( async ( req , res ) => {
170114 // our Materia integration relies on postmessage
171115 // this is only here for Materia to redirect to
172116 // once a resource is selected. Normally,
173117 // the client will close the browser before this loads
174- // In the future, this will have to receive & validate
175- // a normal LTI ContentItemSelectionRequest results and
176- // pass it to the client
177- if ( req . query . embed_type && req . query . url ) {
118+ const materia_jwks = await fetch ( `${ config . clientMateriaHost } /.well-known/jwks.json` ) . then ( r =>
119+ r . json ( )
120+ )
121+ const keystore = jose . JWK . createKeyStore ( )
122+ for ( const jwk of materia_jwks . keys ) {
123+ await keystore . add ( jwk )
124+ }
125+ const result = await jose . JWS . createVerify ( keystore ) . verify ( req . body . JWT )
126+ const payload = JSON . parse ( result . payload . toString ( ) )
127+
128+ if ( payload . type === 'ltiResourceLink' && req . url ) {
178129 res . type ( 'text/html' )
179130 res . send ( `<html><head></head><body>Materia Widget Selection Complete</body></html>` )
180131 }
@@ -185,18 +136,8 @@ router
185136 . get ( [ requireCurrentUser , requireCanViewEditor ] )
186137 . get ( async ( req , res ) => {
187138 const { draftId, contentId, nodeId } = req . query
188- const clientBaseUrl = baseUrl ( req , false )
189- const serverBaseUrl = baseUrl ( req )
190139 const currentDocument = await Draft . fetchDraftByVersion ( draftId , contentId )
191- const method = 'POST'
192- const endpoint = `${ config . clientMateriaHost } /lti/picker`
193- const launchParams = contentSelectionParams (
194- currentDocument ,
195- nodeId ,
196- req . currentUser ,
197- clientBaseUrl ,
198- serverBaseUrl
199- )
140+ const endpoint = `${ config . clientMateriaHost } /ltilaunch/`
200141
201142 await materiaEvent . insertLtiPickerLaunchEvent ( {
202143 userId : req . currentUser . id ,
@@ -207,7 +148,110 @@ router
207148 ip : req . ip
208149 } )
209150
210- renderLtiLaunch ( launchParams , method , endpoint , res )
151+ const loginHintObj = {
152+ nodeId : nodeId ,
153+ documentTitle : currentDocument . getTitle ( )
154+ }
155+
156+ const loginHint = base64encode ( JSON . stringify ( loginHintObj ) )
157+ const ltiMessageHint = 'picker'
158+ res . redirect (
159+ `${ config . clientMateriaHost } /init/${ config . oboLtiUuid } /?iss=${ baseUrl ( req ) } &client_id=${
160+ config . oboLtiClientId
161+ } &target_link_uri=${ endpoint } &login_hint=${ loginHint } <i_message_hint=${ ltiMessageHint } `
162+ )
163+ } )
164+
165+ router
166+ . route ( '/materia-lti-auth' )
167+ . get ( [ requireCurrentUser ] )
168+ . get ( async ( req , res ) => {
169+ const { client_id, redirect_uri, login_hint, lti_message_hint, nonce, state } = req . query
170+
171+ if ( lti_message_hint === 'picker' && ! req . currentUser . hasPermission ( 'canViewEditor' ) ) {
172+ renderError (
173+ res ,
174+ 'Action Not Allowed' ,
175+ 'Widget picker event launched by user lacking editor rights.'
176+ )
177+ return
178+ }
179+
180+ const now = Math . floor ( Date . now ( ) / 1000 )
181+
182+ const nodeContext = JSON . parse ( base64decode ( login_hint ) )
183+
184+ const payload = {
185+ iss : baseUrl ( req ) ,
186+ aud : client_id ,
187+ iat : now ,
188+ exp : now + 300 ,
189+ nonce,
190+ sub : req . currentUser . username , // this... may not be necessary?
191+ email : req . currentUser . email ,
192+ given_name : req . currentUser . firstName ,
193+ family_name : req . currentUser . lastName ,
194+ 'https://purl.imsglobal.org/spec/lti/claim/lis' : {
195+ person_sourcedid : req . currentUser . username
196+ } ,
197+ 'https://purl.imsglobal.org/spec/lti/claim/version' : '1.3.0' ,
198+ 'https://purl.imsglobal.org/spec/lti/claim/message_type' :
199+ lti_message_hint === 'picker' ? 'LtiDeepLinkingRequest' : 'LtiResourceLinkRequest' ,
200+ 'https://purl.imsglobal.org/spec/lti/claim/deployment_id' : 'obojobo-deployment-id' ,
201+ 'https://purl.imsglobal.org/spec/lti/claim/target_link_uri' :
202+ lti_message_hint === 'picker' ? redirect_uri : nodeContext . widgetEndpoint ,
203+ // transporting the node ID of the Materia node being embedded via the login hint
204+ // there may be a more intelligent way of doing this?
205+ 'https://purl.imsglobal.org/spec/lti/claim/resource_link' : { id : nodeContext . nodeId } ,
206+ 'https://purl.imsglobal.org/spec/lti/claim/roles' : [
207+ // this may be a bit naive, but we can probably assume that
208+ // students will not be able to use the draft editor, so anybody
209+ // getting this far is an instructor
210+ req . currentUser . hasPermission ( 'canViewEditor' )
211+ ? 'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor'
212+ : 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'
213+ ] ,
214+ // need to somehow address this
215+ 'https://purl.imsglobal.org/spec/lti/claim/context' : {
216+ id : nodeContext . nodeId ,
217+ title : nodeContext . draftTitle
218+ }
219+ }
220+ if ( lti_message_hint === 'picker' ) {
221+ payload [ 'https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings' ] = {
222+ deep_link_return_url : `${ baseUrl ( req ) } /materia-lti-picker-return` ,
223+ accept_types : [ 'ltiResourceLink' ] ,
224+ accept_presentation_document_targets : [ 'iframe' , 'window' , 'embed' ]
225+ }
226+ }
227+
228+ const idToken = jwt . sign ( payload , config . oboPrivateRsaKey , {
229+ algorithm : 'RS256' ,
230+ keyid : config . oboJwtKey
231+ } )
232+
233+ res . set ( 'Content-Type' , 'text/html' )
234+ res . send ( `<html><body>
235+ <form id="form" method="POST" action="${ redirect_uri } ">
236+ <input type="hidden" name="instance" value="${ nodeContext . nodeId } " />
237+ <input type="hidden" name="csrfmiddlewaretoken" value="${ csrfCookie ( req ) } " />
238+ <input type="hidden" name="state" value="${ state } " />
239+ <input type="hidden" name="id_token" value="${ idToken } " />
240+ </form>
241+ <script>document.getElementById('form').submit()</script>
242+ </body></html>` )
211243 } )
212244
245+ // this might make more sense somewhere else, but currently it only matters for the materia integration
246+ router . route ( '/.well-known/jwks.json' ) . get ( async ( req , res ) => {
247+ const key = await jose . JWK . asKey ( config . oboPrivateRsaKey , 'pem' )
248+
249+ const jwk = key . toJSON ( )
250+ jwk . use = 'sig'
251+ jwk . alg = 'RS256'
252+ jwk . kid = config . oboJwtKey
253+
254+ res . json ( { keys : [ jwk ] } )
255+ } )
256+
213257module . exports = router
0 commit comments