Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,20 @@ public class StateAuthorizationRedirectHandler implements AuthorizationRedirectH

private final JsonStringEncoder encoder = JsonStringEncoder.getInstance();
private final String htmlTemplate;
private final String library;

private String name = "/uk/ac/ox/ctl/lti13/step-1-redirect.html";
private final String htmlName = "/uk/ac/ox/ctl/lti13/step-1-redirect.html";
private final String libraryName = "/uk/ac/ox/ctl/lti13/library.js";

public StateAuthorizationRedirectHandler() {
try {
htmlTemplate = StringReader.readString(getClass().getResourceAsStream(name));
htmlTemplate = StringReader.readString(getClass().getResourceAsStream(htmlName));
library = StringReader.readString(getClass().getResourceAsStream(libraryName));
} catch (IOException e) {
throw new IllegalStateException("Failed to read "+ name, e);
throw new IllegalStateException("Failed to initialise resources. " + e.getMessage(), e);
}
}

public void setName(String name) {
this.name = name;
}

/**
* This sends the user off, but before that it saves data in the user's browser's sessionStorage so that
* when they come back we can check that noting malicious is going on.
Expand All @@ -53,7 +52,8 @@ public void sendRedirect(HttpServletRequest request, HttpServletResponse respons
final String body = htmlTemplate
.replaceFirst("@@state@@", state)
.replaceFirst("@@url@@", url)
.replaceFirst("@@nonce@@", nonce);
.replaceFirst("@@nonce@@", nonce)
.replaceFirst("// @@library@@", library);
writer.append(body);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,25 +44,24 @@ public class StateCheckingAuthenticationSuccessHandler extends

private final OptimisticAuthorizationRequestRepository authorizationRequestRepository;
private final String htmlTemplate;
private final String library;

private String name = "/uk/ac/ox/ctl/lti13/step-3-redirect.html";
private final String htmlName = "/uk/ac/ox/ctl/lti13/step-3-redirect.html";
private final String libraryName = "/uk/ac/ox/ctl/lti13/library.js";

/**
* @param authorizationRequestRepository The repository holding authorization requests
*/
public StateCheckingAuthenticationSuccessHandler(OptimisticAuthorizationRequestRepository authorizationRequestRepository) {
this.authorizationRequestRepository = authorizationRequestRepository;
try {
htmlTemplate = StringReader.readString(getClass().getResourceAsStream(name));
htmlTemplate = StringReader.readString(getClass().getResourceAsStream(htmlName));
library = StringReader.readString(getClass().getResourceAsStream(libraryName));
} catch (IOException e) {
throw new IllegalStateException("Failed to read " + name, e);
throw new IllegalStateException("Failed to initialise resources. " + e.getMessage(), e);
}
}

public void setName(String name) {
this.name = name;
}

/**
* Calls the parent class {@code handle()} method to forward or redirect to the target
* URL, and then calls {@code clearAuthenticationAttributes()} to remove any leftover
Expand Down Expand Up @@ -106,6 +105,7 @@ protected void handle(HttpServletRequest request, HttpServletResponse response,
.replaceFirst("@@state@@", state)
.replaceFirst("@@url@@", targetUrl)
.replaceFirst("@@nonce@@", nonce)
.replaceFirst("// @@library@@", library)
);
}

Expand Down
2 changes: 1 addition & 1 deletion src/main/java/uk/ac/ox/ctl/lti13/utils/StringReader.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public class StringReader {
public static String readString(InputStream inputStream) throws IOException {
StringBuilder textBuilder = new StringBuilder();
try (Reader reader = new BufferedReader(new InputStreamReader
(inputStream, Charset.forName(StandardCharsets.UTF_8.name())))) {
(inputStream, StandardCharsets.UTF_8))) {
char[] buffer = new char[1024];
int len;
while ((len = reader.read(buffer)) != -1) {
Expand Down
140 changes: 140 additions & 0 deletions src/main/resources/uk/ac/ox/ctl/lti13/library.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* A simple LTI PostMessage client for communicating with an LTI platform.
* @see https://www.imsglobal.org/spec/lti-pm-s/v0p1
*/
class LtiPostMessageClient {
/**
* Construct a new LtiPostMessageClient.
* @param targetOrigin The target origin to send messages to.
* @param timeout The default timeout for responses in milliseconds.
*/
constructor({targetOrigin = '*', timeout = 5000} = {}) {
this.targetOrigin = targetOrigin;
this.timeout = timeout;
}

/**
* Attempt to generate a UUID and falls back to a random string.
* @returns {string}
*/
uuid() {
if (self.crypto && self.crypto.randomUUID) {
return self.crypto.randomUUID();
} else {
// IE 11 Doesn't have randomUUID so fall back to short random string.
return (Math.random() + 1).toString(36).substring(2, 5)
}
}

getTarget() {
let target;
if (window.parent === window) {
// We're not in an iframe so must have been opened by the platform
target = window.opener;
} else {
// We're in an iframe so the platform is our parent
if (window.parent) {
target = window.parent;
}
}
return target
}

/**
* Enable or disable debug logging of all sent and received postMessages.
* @param enable If true enable some debug logging to the browser console.
*/
debug(enable) {
if (enable && !this.debugHandler) {
this.debugHandler = (event) => {
console.debug('Received message:', event)
}
window.addEventListener('message', this.debugHandler);
} else {
if (this.debugHandler) {
window.removeEventListener('message', this.debugHandler)
}
}
}

postMessage(message) {
const origin = this.targetOrigin;
let responseHandler
let timeoutId;
const target = this.getTarget()
return new Promise((resolve, reject) => {
responseHandler = (event) => {
// This isn't a message we're expecting
if (typeof event.data !== "object") {
return;
}
// Validate it's the response type you expect
if (event.data.subject !== message.subject + ".response") {
return;
}
// Validate the message id matches the id you sent
if (event.data.message_id !== message.message_id) {
return;
}
// Validate that the event's origin is the same as the derived platform origin
if (origin !== '*' && event.origin !== origin) {
return;
}
// handle errors
if (event.data.error) {
// handle errors (message and code)
reject(new Error('Postmessage failure: '+event.data.error.code+' - '+event.data.error.message));
} else {
resolve(event)
}
}

try {
window.addEventListener('message', responseHandler);
timeoutId = window.setTimeout(() => reject(new Error('timeout')), this.timeout);
if (this.debugHandler) {
console.debug('Sending message:', message)
}
target.postMessage(message, this.targetOrigin);
} catch (error) {
reject(error);
}
}).finally(() => {
window.removeEventListener('message', responseHandler)
window.clearTimeout(timeoutId);
})
}

/**
* Store some data using LTI storage.
* @param key The key to store the data against.
* @param value The value to store.
* @returns {Promise<void>} A promise that resolves when the data is successfully stored (we've had confirmation).
*/
async setData(key, value) {
const id = this.uuid()
const message = {
subject: 'lti.put_data',
key: key,
value: value,
message_id: id
}
return this.postMessage(message);
}

/**
* Retrieve some data using LTI storage.
* @param key The key to retrieve the data for.
* @returns {Promise<{value: *, event: *}>} A promise that resolves with an object containing the value and the original event.
*/
async getData(key) {
const id = this.uuid()
const message = {
subject: 'lti.get_data',
key: key,
message_id: id
}
return this.postMessage(message)
.then((event => ({value: event.data.value, event})))
}
}
111 changes: 23 additions & 88 deletions src/main/resources/uk/ac/ox/ctl/lti13/step-1-redirect.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,43 +10,19 @@ <h1>Loading...</h1>
<script>
function showError(message) {
const h1 = document.createElement("h1")
h1.textContent = "Error: "+ message
h1.textContent = "Error: " + message
document.body.append(h1)
}

const state = "@@state@@";
const url = "@@url@@";
const nonce = "@@nonce@@";

// Have we set the values in the storage?
let setState = false
let setNonce = false

try {
/**
* Attempt to generate a uuid, falls back to a random string on
* @returns {string}
*/
function uuid() {
if (self.crypto && self.crypto.randomUUID) {
return self.crypto.randomUUID();
} else {
// IE 11 Doesn't have randomUUID so fall back to short random string.
return (Math.random() + 1).toString(36).substring(2, 5)
}
}
const platformOrigin = "*"; // Canvas doesn't support origin.
let targetFrame = window.parent || window.opener

if (targetFrame === window) {
// We don't have anywhere to send the message to, so we can't do anything.
// This seems to happen with Canvas when the LTI launch is done in a new window/tab.
console.log('We cannot store state so relying on cookies (which might be blocked)');
document.location = url;
}
// @@library@@

try {
// If we don't get any response from the platform show something to the user.
setTimeout(function() {
setTimeout(function () {
// Mobile apps users will see this the first time as they don't support the storage platform
// but their LTI launch says they do. However they do support cookies so we should be able
// to use the session when they complete the LTI launch.
Expand All @@ -56,69 +32,28 @@ <h1>Loading...</h1>
}, 2000)
}, 5000)

let setState = false
let setNonce = false

let stateUuid = uuid();
postAndHandle(targetFrame, {
"subject": "lti.put_data",
"message_id": stateUuid,
"key": "state_"+ state,
"value": state
} , platformOrigin, function(event) {
setState = true
redirectIfValid()
})
const postMessage = new LtiPostMessageClient()

let nonceUuid = uuid();
postAndHandle(targetFrame, {
"subject": "lti.put_data",
"message_id": nonceUuid,
"key": "nonce_"+ nonce,
"value": nonce
} , platformOrigin, function(event) {
setNonce = true
redirectIfValid()
})
postMessage.setData("state_" + state, state)
.then(() => {
setState = true
redirectIfValid()
})
.catch((error) => {
showError("Error setting state: " + error.message)
})
postMessage.setData("nonce_" + nonce, nonce)
.then(() => {
setNonce = true
redirectIfValid()
})
.catch((error) => {
showError("Error setting nonce: " + error.message)
})

/**
* Send a message and handle its response.
* @param target
* @param message
* @param origin
* @param onResponse
*/
function postAndHandle(target, message, origin, onResponse) {
function handler(event) {
// This isn't a message we're expecting
if (typeof event.data !== "object"){
return;
}
// Validate it's the response type you expect
if (event.data.subject !== message.subject+ ".response") {
return;
}
// Validate the message id matches the id you sent
if (event.data.message_id !== message.message_id) {
return;
}
// Validate that the event's origin is the same as the derived platform origin
if (origin !== '*' && event.origin !== origin) {
return;
}
// handle errors
if (event.data.error){
// handle errors (message and code)
console.log(event.data.error)
return;
}
onResponse(event)
// Cleanup now we've got our message.
window.removeEventListener('message', handler)
}
window.addEventListener('message', handler);
// Now the handler is setup we can send our message.
target.postMessage(message, origin);
}

function redirectIfValid() {
// Only redirect once we've had confirmation both are set
if (setNonce && setState) {
Expand Down
Loading