Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/obonode/obojobo-chunks-materia/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@
"singleQuote": true
},
"dependencies": {
"jsonwebtoken": "^9.0.2",
"node-jose": "^2.2.0",
"oauth-signature": "^1.5.0",
"simple-oauth2": "^5.1.0",
"uuid": "^8.3.2",
"xml2js": "0.5.0"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,27 @@
"oauthKey": "materia-lti-key",
"oauthSecret": "materia-lti-secret",
"oboFamilyCode": "obojobo-next",
"oboName": "Obojobo Next",
"oboGuid": "obojobo.next.default.guid"
"oboGuid": "obojobo.next.default.guid",
"oboJwtKey": "obojobo-jwt-key",
"oboLtiClientId": "1234",
"oboLtiUuid": "00000000-0000-0000-0000-000000000000",
"oboName": "Obojobo Next"
},
"development": {
"clientMateriaHost": "https://localhost",
"optionalOboServerHost": "https://host.docker.internal:8080",
"oboGuid": "obojobo.next.local.dev"
"clientMateriaHost": "http://localhost:420",
"oboGuid": "obojobo.next.local.dev.guid",
"oboLtiUuid": "00000000-0000-0000-0000-000000000000",
"oboPrivateRsaKey": {"ENV": "OBO_PRIVATE_RSA_KEY"},
"optionalOboServerHost": "https://host.docker.internal:8080"
},
"production": {
"clientMateriaHost": {"ENV": "MATERIA_HOST"},
"oauthKey": {"ENV": "MATERIA_OAUTH_KEY"},
"oauthSecret": {"ENV": "MATERIA_OAUTH_SECRET"},
"clientMateriaHost": {"ENV": "MATERIA_HOST"},
"oboGuid": {"ENV": "OBO_LTI_GUID"}
"oboGuid": {"ENV": "OBO_LTI_GUID"},
"oboJwtKey": {"ENV": "OBO_JWT_KEY"},
"oboLtiClientId": {"ENV": "OBO_LTI_CLIENTID"},
"oboLtiUuid": {"ENV": "OBO_LTI_UUID"},
"oboPrivateRsaKey": {"ENV": "OBO_PRIVATE_RSA_KEY"}
}
}
260 changes: 152 additions & 108 deletions packages/obonode/obojobo-chunks-materia/server/index.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,9 @@
const jose = require('node-jose')
const jwt = require('jsonwebtoken')
const router = require('express').Router() //eslint-disable-line new-cap
const logger = require('obojobo-express/server/logger')
const uuid = require('uuid').v4
const bodyParser = require('body-parser')
const oboEvents = require('obojobo-express/server/obo_events')
const Visit = require('obojobo-express/server/models/visit')
const Draft = require('obojobo-express/server/models/draft')
const config = require('obojobo-express/server/config').materiaLti
const {
widgetLaunchParams,
contentSelectionParams,
signLtiParams,
verifyScorePassback,
expandLisResultSourcedId,
createPassbackResult,
getValuesFromPassbackXML
} = require('./route-helpers')
const materiaEvent = require('./materia-event')
const {
requireCurrentUser,
Expand All @@ -29,6 +18,9 @@ oboEvents.on('EDITOR_SETTINGS', event => {
}
})

const base64encode = str => Buffer.from(str).toString('base64')
const base64decode = str => Buffer.from(str, 'base64').toString()

// util to get a baseUrl to build urls for for this server
// option `isForServerRequest` indicates the url will be
// used by materia server to communicate with obo server
Expand All @@ -39,75 +31,24 @@ const baseUrl = (req, isForServerRequest = true) => {
return `${req.protocol}://${req.get('host')}`
}

// util to get a csrf token from an existing request
// feels like a bit of a hack but seems to work
const csrfCookie = req => {
const cookies = req.headers.cookie.split(';')
let cookieCSRFToken = cookies.find(cookie => cookie.includes('csrftoken'))
if (cookieCSRFToken) {
cookieCSRFToken = cookieCSRFToken.split('=')[1]
}
return cookieCSRFToken
}

const renderError = (res, title, message) => {
res.set('Content-Type', 'text/html')
res.send(
`<html><head><title>Error - ${title}</title></head><body><h1>${title}</h1><p>${message}</p></body></html>`
)
}

// constructs a signed lti request, renders on client and has the client submit it
const renderLtiLaunch = (paramsIn, method, endpoint, res) => {
const params = signLtiParams(paramsIn, method, endpoint)
const keys = Object.keys(params)
const htmlInput = keys
.map(key => `<input type="hidden" name="${key}" value="${params[key]}"/>`)
.join('')

res.set('Content-Type', 'text/html')
res.send(`<html>
<body>
<form id="form" method="${method}" action="${endpoint}" >${htmlInput}</form>
<script>document.getElementById('form').submit()</script>
</body></html>`)
}

// receives scores passed back from Materia
router
.route('/materia-lti-score-passback')
.post(bodyParser.text({ type: '*/*' }))
.post(async (req, res) => {
let success
let visit = {}
let passBackData = {}
let sourcedIdData = {}
const messageId = uuid()

try {
const verified = verifyScorePassback(req.headers, req.body, req.originalUrl, baseUrl(req))
if (!verified) throw Error('Signature verification failed')
passBackData = await getValuesFromPassbackXML(req.body)
sourcedIdData = expandLisResultSourcedId(passBackData.sourcedId)
visit = await Visit.fetchById(sourcedIdData.visitId)
success = true
} catch (e) {
logger.error(e)
success = false
}

await materiaEvent.insertLtiScorePassbackEvent({
userId: visit.user_id,
draftId: visit.draft_id,
contentId: visit.draft_content_id,
resourceLinkId: sourcedIdData.nodeId,
messageRefId: passBackData.messageId,
lisResultSourcedId: passBackData.sourcedId,
messageId,
success,
ip: req.ip,
score: passBackData.score,
materiaHost: config.clientMateriaHost,
isPreview: visit.is_preview,
visitId: sourcedIdData.visitId
})

const xml = createPassbackResult({ success, messageId, messageRefId: passBackData.messageId })

res.status(success ? 200 : 500)
res.type('application/xml')
res.send(xml)
})

// route to launch a materia widget
// the viewer component sends the widget url
// to this url and we build a page with all the params
Expand All @@ -120,7 +61,6 @@ router
// use the visitId to get the src from the materia chunk
const currentDocument = await req.currentVisit.draftDocument
const materiaNode = currentDocument.getChildNodeById(req.query.nodeId)
const method = 'POST'

if (!materiaNode) {
renderError(
Expand All @@ -132,49 +72,60 @@ router
}

const materiaOboNodeId = materiaNode.node.id
const endpoint = materiaNode.node.content.src
const widgetEndpoint = materiaNode.node.content.src

// verify the endpoint is the configured materia server
if (!endpoint.startsWith(config.clientMateriaHost)) {
if (!widgetEndpoint.startsWith(config.clientMateriaHost)) {
renderError(
res,
'Materia Widget Url Restricted',
`The widget url ${endpoint} does not match the configured Materia server located at ${config.clientMateriaHost}.`
`The widget url ${widgetEndpoint} does not match the configured Materia server located at ${config.clientMateriaHost}.`
)
return
}

const launchParams = widgetLaunchParams(
currentDocument,
req.currentVisit,
req.currentUser,
materiaOboNodeId,
baseUrl(req)
)

await materiaEvent.insertLtiLaunchWidgetEvent({
userId: req.currentUser.id,
draftId: currentDocument.draftId,
contentId: currentDocument.contentId,
visitId: req.currentVisit.id,
isPreview: req.currentVisit.is_preview,
lisResultSourcedId: launchParams.lis_result_sourcedid,
resourceLinkId: launchParams.resource_link_id,
endpoint,
lisResultSourcedId: `${req.currentVisit.id}__${materiaOboNodeId}`,
resourceLinkId: `${req.currentVisit.resource_link_id}__${req.currentVisit.draft_id}__${materiaOboNodeId}`,
widgetEndpoint,
ip: req.ip
})
renderLtiLaunch(launchParams, method, endpoint, res)
const endpoint = `${config.clientMateriaHost}/ltilaunch/`

const loginHintObj = {
nodeId: materiaOboNodeId,
widgetEndpoint
}
const loginHint = base64encode(JSON.stringify(loginHintObj))
const ltiMessageHint = 'resource'
res.redirect(
`${config.clientMateriaHost}/init/${config.oboLtiUuid}/?iss=${baseUrl(req)}&client_id=${
config.oboLtiClientId
}&target_link_uri=${endpoint}&login_hint=${loginHint}&lti_message_hint=${ltiMessageHint}`
)
})

router.route('/materia-lti-picker-return').all((req, res) => {
router.route('/materia-lti-picker-return').post(async (req, res) => {
// our Materia integration relies on postmessage
// this is only here for Materia to redirect to
// once a resource is selected. Normally,
// the client will close the browser before this loads
// In the future, this will have to receive & validate
// a normal LTI ContentItemSelectionRequest results and
// pass it to the client
if (req.query.embed_type && req.query.url) {
const materia_jwks = await fetch(`${config.clientMateriaHost}/.well-known/jwks.json`).then(r =>
r.json()
)
const keystore = jose.JWK.createKeyStore()
for (const jwk of materia_jwks.keys) {
await keystore.add(jwk)
}
const result = await jose.JWS.createVerify(keystore).verify(req.body.JWT)
const payload = JSON.parse(result.payload.toString())

if (payload.type === 'ltiResourceLink' && req.url) {
res.type('text/html')
res.send(`<html><head></head><body>Materia Widget Selection Complete</body></html>`)
}
Expand All @@ -185,18 +136,8 @@ router
.get([requireCurrentUser, requireCanViewEditor])
.get(async (req, res) => {
const { draftId, contentId, nodeId } = req.query
const clientBaseUrl = baseUrl(req, false)
const serverBaseUrl = baseUrl(req)
const currentDocument = await Draft.fetchDraftByVersion(draftId, contentId)
const method = 'POST'
const endpoint = `${config.clientMateriaHost}/lti/picker`
const launchParams = contentSelectionParams(
currentDocument,
nodeId,
req.currentUser,
clientBaseUrl,
serverBaseUrl
)
const endpoint = `${config.clientMateriaHost}/ltilaunch/`

await materiaEvent.insertLtiPickerLaunchEvent({
userId: req.currentUser.id,
Expand All @@ -207,7 +148,110 @@ router
ip: req.ip
})

renderLtiLaunch(launchParams, method, endpoint, res)
const loginHintObj = {
nodeId: nodeId,
documentTitle: currentDocument.getTitle()
}

const loginHint = base64encode(JSON.stringify(loginHintObj))
const ltiMessageHint = 'picker'
res.redirect(
`${config.clientMateriaHost}/init/${config.oboLtiUuid}/?iss=${baseUrl(req)}&client_id=${
config.oboLtiClientId
}&target_link_uri=${endpoint}&login_hint=${loginHint}&lti_message_hint=${ltiMessageHint}`
)
})

router
.route('/materia-lti-auth')
.get([requireCurrentUser])
.get(async (req, res) => {
const { client_id, redirect_uri, login_hint, lti_message_hint, nonce, state } = req.query

if (lti_message_hint === 'picker' && !req.currentUser.hasPermission('canViewEditor')) {
renderError(
res,
'Action Not Allowed',
'Widget picker event launched by user lacking editor rights.'
)
return
}

const now = Math.floor(Date.now() / 1000)

const nodeContext = JSON.parse(base64decode(login_hint))

const payload = {
iss: baseUrl(req),
aud: client_id,
iat: now,
exp: now + 300,
nonce,
sub: req.currentUser.username, // this... may not be necessary?
email: req.currentUser.email,
given_name: req.currentUser.firstName,
family_name: req.currentUser.lastName,
'https://purl.imsglobal.org/spec/lti/claim/lis': {
person_sourcedid: req.currentUser.username
},
'https://purl.imsglobal.org/spec/lti/claim/version': '1.3.0',
'https://purl.imsglobal.org/spec/lti/claim/message_type':
lti_message_hint === 'picker' ? 'LtiDeepLinkingRequest' : 'LtiResourceLinkRequest',
'https://purl.imsglobal.org/spec/lti/claim/deployment_id': 'obojobo-deployment-id',
'https://purl.imsglobal.org/spec/lti/claim/target_link_uri':
lti_message_hint === 'picker' ? redirect_uri : nodeContext.widgetEndpoint,
// transporting the node ID of the Materia node being embedded via the login hint
// there may be a more intelligent way of doing this?
'https://purl.imsglobal.org/spec/lti/claim/resource_link': { id: nodeContext.nodeId },
'https://purl.imsglobal.org/spec/lti/claim/roles': [
// this may be a bit naive, but we can probably assume that
// students will not be able to use the draft editor, so anybody
// getting this far is an instructor
req.currentUser.hasPermission('canViewEditor')
? 'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor'
: 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'
],
// need to somehow address this
'https://purl.imsglobal.org/spec/lti/claim/context': {
id: nodeContext.nodeId,
title: nodeContext.draftTitle
}
}
if (lti_message_hint === 'picker') {
payload['https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings'] = {
deep_link_return_url: `${baseUrl(req)}/materia-lti-picker-return`,
accept_types: ['ltiResourceLink'],
accept_presentation_document_targets: ['iframe', 'window', 'embed']
}
}

const idToken = jwt.sign(payload, config.oboPrivateRsaKey, {
algorithm: 'RS256',
keyid: config.oboJwtKey
})

res.set('Content-Type', 'text/html')
res.send(`<html><body>
<form id="form" method="POST" action="${redirect_uri}">
<input type="hidden" name="instance" value="${nodeContext.nodeId}" />
<input type="hidden" name="csrfmiddlewaretoken" value="${csrfCookie(req)}" />
<input type="hidden" name="state" value="${state}" />
<input type="hidden" name="id_token" value="${idToken}" />
</form>
<script>document.getElementById('form').submit()</script>
</body></html>`)
})

// this might make more sense somewhere else, but currently it only matters for the materia integration
router.route('/.well-known/jwks.json').get(async (req, res) => {
const key = await jose.JWK.asKey(config.oboPrivateRsaKey, 'pem')

const jwk = key.toJSON()
jwk.use = 'sig'
jwk.alg = 'RS256'
jwk.kid = config.oboJwtKey

res.json({ keys: [jwk] })
})

module.exports = router
Loading
Loading