Skip to content

Commit cf1d899

Browse files
Merge pull request #2138 from FrenjaminBanklin/feature/materia-lti1p3
Modifies Materia Obonode to support LTI 1.3 version.
2 parents 7455481 + d0a8da7 commit cf1d899

File tree

5 files changed

+502
-383
lines changed

5 files changed

+502
-383
lines changed

packages/obonode/obojobo-chunks-materia/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@
2727
"singleQuote": true
2828
},
2929
"dependencies": {
30+
"jsonwebtoken": "^9.0.2",
31+
"node-jose": "^2.2.0",
3032
"oauth-signature": "^1.5.0",
33+
"simple-oauth2": "^5.1.0",
3134
"uuid": "^8.3.2",
3235
"xml2js": "0.5.0"
3336
},

packages/obonode/obojobo-chunks-materia/server/config/materia-lti.json

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,27 @@
44
"oauthKey": "materia-lti-key",
55
"oauthSecret": "materia-lti-secret",
66
"oboFamilyCode": "obojobo-next",
7-
"oboName": "Obojobo Next",
8-
"oboGuid": "obojobo.next.default.guid"
7+
"oboGuid": "obojobo.next.default.guid",
8+
"oboJwtKey": "obojobo-jwt-key",
9+
"oboLtiClientId": "1234",
10+
"oboLtiUuid": "00000000-0000-0000-0000-000000000000",
11+
"oboName": "Obojobo Next"
912
},
1013
"development": {
11-
"clientMateriaHost": "https://localhost",
12-
"optionalOboServerHost": "https://host.docker.internal:8080",
13-
"oboGuid": "obojobo.next.local.dev"
14+
"clientMateriaHost": "http://localhost:420",
15+
"oboGuid": "obojobo.next.local.dev.guid",
16+
"oboLtiUuid": "00000000-0000-0000-0000-000000000000",
17+
"oboPrivateRsaKey": {"ENV": "OBO_PRIVATE_RSA_KEY"},
18+
"optionalOboServerHost": "https://host.docker.internal:8080"
1419
},
1520
"production": {
21+
"clientMateriaHost": {"ENV": "MATERIA_HOST"},
1622
"oauthKey": {"ENV": "MATERIA_OAUTH_KEY"},
1723
"oauthSecret": {"ENV": "MATERIA_OAUTH_SECRET"},
18-
"clientMateriaHost": {"ENV": "MATERIA_HOST"},
19-
"oboGuid": {"ENV": "OBO_LTI_GUID"}
24+
"oboGuid": {"ENV": "OBO_LTI_GUID"},
25+
"oboJwtKey": {"ENV": "OBO_JWT_KEY"},
26+
"oboLtiClientId": {"ENV": "OBO_LTI_CLIENTID"},
27+
"oboLtiUuid": {"ENV": "OBO_LTI_UUID"},
28+
"oboPrivateRsaKey": {"ENV": "OBO_PRIVATE_RSA_KEY"}
2029
}
2130
}
Lines changed: 152 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,9 @@
1+
const jose = require('node-jose')
2+
const jwt = require('jsonwebtoken')
13
const 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')
54
const oboEvents = require('obojobo-express/server/obo_events')
6-
const Visit = require('obojobo-express/server/models/visit')
75
const Draft = require('obojobo-express/server/models/draft')
86
const 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')
187
const materiaEvent = require('./materia-event')
198
const {
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+
4245
const 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}&lti_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}&lti_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+
213257
module.exports = router

0 commit comments

Comments
 (0)