Safari 13.1 and LTI Integration

Instructure
Instructure
6 2 1,022

This blog post was authored by Xander Moffatt who is a Software Engineer on our Interoperability Team.

tl;dr
Safari blocks all 3rd-party cookies by default, which breaks LTI tools that rely on setting cookies when launched in an iframe. Instead, it exposes a new API for getting user-permitted access to set cookies from an iframe, which works though requires jumping through some fun hoops.

Background

Safari has been working towards this step for a few years now, since the introduction of the Storage Access API and Intelligent Tracking Prevention. These features were introduced to limit cross-site tracking that users never agreed to, and to preserve their privacy. These limitations have also grown more strict over the years, with most third-party cookies already being blocked by the time of the newest release, 13.1.

Before this release, third-party cookies were allowed to be set once the domain has set a first-party cookie, which occurs when the tool is launched in the parent browser window instead of a child iframe. Canvas implemented this change to launch the tool in the parent window, let it set a cookie, and provide a redirect url so that the tool is launched again in an iframe, with the ability to set third-party cookies.

This release makes Safari the first mainstream browser to fully block third-party cookies by default, though Chrome aims to ship the same features by 2022. The Storage Access API should also be made standard, providing a known way for LTI tools to still be functional. Note that this behavior can be turned off, by disabling the Preferences > Privacy > Prevent cross-site tracking checkbox.

When this behavior is enabled, the current behavior of an LTI tool launch in Canvas is to get stuck in an infinite loop, since Storage Access hasn’t been granted and so the cookie can never be set.

Storage Access API

There are only two methods in the Storage Access API, but they are more complex than they look. document.hasStorageAccess asynchronously resolves to a boolean indicating whether the iframe already has access to set its own cookies. In practice, this is almost never true until a call to the next method, document.requestStorageAccess. This method also asynchronously resolves to a boolean indicating whether the iframe now has access. The hoops that require jumping through come with this method.

  • this method must be called upon a user gesture (like tap/click). this means the user must click a button and the listener for this button must directly call requestStorageAccess. any calls that aren’t inside a listener will immediately return false.
  • this method won’t return true for a domain in an iframe unless the user has interacted with the domain in a first-party context. This is to make sure that the user knows and trusts this domain. Interaction in this case means another user gesture like a tap or click.
  • this method will return true if the user has had storage access granted in the last 24 hours.
  • once this has been called from a user gesture, the user has interacted in a first-party context, and then the request is sent again from a third-party context, then Safari will prompt the user using a browser dialog box to allow storage access. Once the user clicks Allow, then this method will return true and the tool can finally set the cookies it needs to authenticate its user.

Solution

There are a couple of ways to approach this situation from a Canvas-launching-LTI tools standpoint, and both of them are on the tool side, as opposed to the Canvas side. Canvas continues its behavior of providing a redirect url when the tool requests a full window launch, but the tool has some decisions to make.


If your LTI tool can handle being stateless and not setting cookies (ie it doesn’t require logging in, or the login process is fast so can be done on every launch), do it. Move any non-login cookies to window.ENV or something, let the user login if needed, and just plan on that whole flow happening on every launch.


If your LTI tool requires storing state in cookies and keeping the user logged in, there is a slightly more complex process to work with an in-line Canvas launch. Note that the Storage Access API happens in Javascript, but most LTI tools want to set httpOnly cookies from the server for sensitive cookies like a login token, so once the tool has Storage Access, a final redirect back to the server to set cookies and render the UI will be needed.

  1. When the tool launches, use document.hasStorageAccess to check if the tool already has Storage Access. This will most likely never be true, but if it is, redirect to the tool server to set cookies and render the UI.
  2. Request Storage Access using a user button click that calls document.requestStorageAccess. If the user has granted Storage Access within the last 24 hours, this will be granted. If granted, redirect to the tool server to set cookies and render the UI.
  3. If the request fails, then it’s time to get user interaction in a first-party context. Send a postMessage to Canvas requesting a full window launch, providing the tool’s normal launch url.
  4. Once that custom postMessage has been sent, Canvas will launch the tool again, in a full window. Canvas will send a platform_redirect_url in the request parameters, which is how you can tell it’s a full window launch. Get user interaction by having them click a button, and on that click redirect to the url Canvas supplied.
  5. Canvas will redirect to that url, which means another tool launch in an iframe. The tool will go through steps 1 and 2 again, and this time Safari should prompt the user to grant access. Once that happens, the tool has Storage Access and should redirect to the tool server to set cookies and render the UI.

Efforts are being made to encapsulate this behavior in some sort of gem/module, but since it touches both server- and client-side code it might be hard.

Though this method requires anywhere from 1-3 user button clicks before the app loads, it does provide a non-hacky way of interacting with cookies in Safari.

note that these are snippets that don’t have all variables and dependencies added. They are just for reference!
  • checking for storage access
document.addEventListener("DOMContentLoaded", () => {
if (document.hasStorageAccess) {
document
.hasStorageAccess()
.then((hasStorageAccess) => {
if (hasStorageAccess) {
redirectToSetCookies();
}
})
.catch((err) => console.error(err));
} else {
redirectToSetCookies();
}

ReactDOM.render(
<RequestStorageAccess />,
document.body.appendChild(document.createElement("div"))
);
});‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍
  • requesting storage access
const requestStorageAccess = () => {
document
.requestStorageAccess()
.then(() => redirectToSetCookies())
.catch(() => requestFullWindowLaunch());
};

const buttonText = "Continue to LTI Tool";
const promptText =
"Safari requires your interaction with this tool inside Canvas to keep you logged in.\n" +
"A dialog may appear asking you to allow this tool to use cookies while browsing Canvas.\n" +
"For the best experience, click Allow.";

return (
<InteractionPrompt
action={requestStorageAccess}
buttonText={buttonText}
promptText={promptText}
size="medium"
/>
);‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍
  • requesting a full window launch
const requestFullWindowLaunch = () => {
window.parent.postMessage(
{
messageType: "requestFullWindowLaunch",
data: FULL_WINDOW_LAUNCH_URL,
},
"*"
);
};‍‍‍‍‍‍‍‍‍
  • interact with user in a first-party context
const SafariLaunch = () => {
const redirect = () => {
window.location.replace(PLATFORM_REDIRECT_URL);
};
const buttonText = "Continue to LTI Tool";
const promptText =
"Safari requires your interaction with this tool outside of Canvas before continuing.";

return (
<InteractionPrompt
action={redirect}
buttonText={buttonText}
promptText={promptText}
/>
);
};

document.addEventListener("DOMContentLoaded", () => {
ReactDOM.render(
<SafariLaunch />,
document.body.appendChild(document.createElement("div"))
);
});‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍
  • handle different types of launches, including full window, Safari, and non-Safari
# Safari launch: Full-window launch, solely for first-party user interaction.
# Redirect to Canvas for inline relaunch.
if safari_redirect_required?
@platform_redirect_url = params[:platform_redirect_url]
return render('safari/full_window_launch')
end

if browser.safari?
# Safari launch: request Storage Access, then redirect to
# :relaunch_after_storage_access_request with pertinent cookie info
# If Storage Access request fails, request a full window launch instead.
@id_token = id_token
@state = state
return render('safari/request_storage_access')
end

# Non-Safari launch: set cookies and render app launch
‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍

Resources

https://webkit.org/blog/7675/intelligent-tracking-prevention/
https://webkit.org/blog/8124/introducing-storage-access-api/
https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/

2 Comments
Surveyor

Thanks Xander, we are working our way through these issues at the moment with our LTI App (www.ob3.io).  It definitely seems that getting rid of cookies if at all possible is the best way to go.  One thing I am concerned about is if you do go through the cookie approval process as outlined, it is attached to the current iframe.  If user navigates to another iframe (e.g. by advancing through entries in Modules) that may cause a reload of the LTI tool and I think the permissions will be destroyed when the frame is unloaded.  So I wonder if for example, if the next resource in Modules is another LTI link the user may have to repeat the process again.  

Surveyor

Thanks to @karl (and Xander) for this writeup.

Unfortunately, the UX of this approach leaves a lot to be desired, as it relies on the user to perform a number of clicks correctly. We are trying to develop a deep linking tool that can be triggered from the RCE with the editor button placement, and our tool relies heavily on cookies. Because the tool is always launched into an iframe by Canvas when you click the RCE editor button, that puts our tool into a third party context and cookies are blocked by Safari's default settings. I can't envision teachers and students/parents having to click through the various pieces every 24 hours to get cookies to work in Safari, so this leaves us in a very difficult spot.

You mentioned efforts to encapsulate some of this into a new gem/module, and I'm curious how far along you are with a potential solution to Safari 13.1 cookie restrictions -- could you please provide an update? It would be ideal to have the option of launching our tool in a separate tab/window instead of an iframe to avoid the third party context altogether, but I don't know what is possible on your end.

Thanks!

Trev