From 73493733ae63137ea112b5ed2506b7738994e1ed Mon Sep 17 00:00:00 2001 From: Matthew Buckett Date: Mon, 20 Oct 2025 13:43:44 +0100 Subject: [PATCH 1/2] Extract out JS code across both pages. This also improves our sending of platform messages as we check that we have got a correct response (not an error) when setting data. --- .../StateAuthorizationRedirectHandler.java | 16 +- ...eCheckingAuthenticationSuccessHandler.java | 14 +- .../ac/ox/ctl/lti13/utils/StringReader.java | 2 +- .../resources/uk/ac/ox/ctl/lti13/library.js | 140 ++++++++++++++++++ .../uk/ac/ox/ctl/lti13/step-1-redirect.html | 111 +++----------- .../uk/ac/ox/ctl/lti13/step-3-redirect.html | 98 +++--------- 6 files changed, 197 insertions(+), 184 deletions(-) create mode 100644 src/main/resources/uk/ac/ox/ctl/lti13/library.js diff --git a/src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/web/StateAuthorizationRedirectHandler.java b/src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/web/StateAuthorizationRedirectHandler.java index bbe91e9..428a39c 100644 --- a/src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/web/StateAuthorizationRedirectHandler.java +++ b/src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/web/StateAuthorizationRedirectHandler.java @@ -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 resource. "+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. @@ -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); } } diff --git a/src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/web/StateCheckingAuthenticationSuccessHandler.java b/src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/web/StateCheckingAuthenticationSuccessHandler.java index a2393ee..0e9a89f 100644 --- a/src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/web/StateCheckingAuthenticationSuccessHandler.java +++ b/src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/web/StateCheckingAuthenticationSuccessHandler.java @@ -44,8 +44,10 @@ 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 @@ -53,16 +55,13 @@ public class StateCheckingAuthenticationSuccessHandler extends 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 read. " + 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 @@ -106,6 +105,7 @@ protected void handle(HttpServletRequest request, HttpServletResponse response, .replaceFirst("@@state@@", state) .replaceFirst("@@url@@", targetUrl) .replaceFirst("@@nonce@@", nonce) + .replaceFirst("// @@library@@", library) ); } diff --git a/src/main/java/uk/ac/ox/ctl/lti13/utils/StringReader.java b/src/main/java/uk/ac/ox/ctl/lti13/utils/StringReader.java index 0a7d700..8942aca 100644 --- a/src/main/java/uk/ac/ox/ctl/lti13/utils/StringReader.java +++ b/src/main/java/uk/ac/ox/ctl/lti13/utils/StringReader.java @@ -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) { diff --git a/src/main/resources/uk/ac/ox/ctl/lti13/library.js b/src/main/resources/uk/ac/ox/ctl/lti13/library.js new file mode 100644 index 0000000..109ad13 --- /dev/null +++ b/src/main/resources/uk/ac/ox/ctl/lti13/library.js @@ -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} 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}))) + } +} \ No newline at end of file diff --git a/src/main/resources/uk/ac/ox/ctl/lti13/step-1-redirect.html b/src/main/resources/uk/ac/ox/ctl/lti13/step-1-redirect.html index 7cec850..a1618ec 100644 --- a/src/main/resources/uk/ac/ox/ctl/lti13/step-1-redirect.html +++ b/src/main/resources/uk/ac/ox/ctl/lti13/step-1-redirect.html @@ -10,43 +10,19 @@

Loading...