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.
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.
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.
requestStorageAccess
. any calls that aren’t inside a listener will immediately return false.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.
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.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.postMessage
to Canvas requesting a full window launch, providing the tool’s normal launch url.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.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!
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"))
);
});
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"
/>
);
const requestFullWindowLaunch = () => {
window.parent.postMessage(
{
messageType: "requestFullWindowLaunch",
data: FULL_WINDOW_LAUNCH_URL,
},
"*"
);
};
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"))
);
});
# 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
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/
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.