canvas_lti_third_party_cookies 0.3.3 → 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,77 @@
1
+ const getRelaunchElement = (id) => document.getElementById(`relaunch-${id}`);
2
+ const hide = (el) => (el.style.display = "none");
3
+ const show = (el) => (el.style.display = "flex"); // normally this is 'block', but the containers need to be 'flex'
4
+ const COOKIE_NAME = "can_set_cookies";
5
+
6
+ const openNewWindow = ({ window_type, form_target, width, height }) => {
7
+ switch (window_type) {
8
+ case "popup": {
9
+ return window.open(
10
+ "",
11
+ form_target,
12
+ "toolbar=no,menubar=no,location=no,status=no,resizable,scrollbars," +
13
+ `width=${width},height=${height}`
14
+ );
15
+ }
16
+ case "new_window": {
17
+ return window.open("", form_target);
18
+ }
19
+ }
20
+ };
21
+
22
+ const relaunchOnLogin = () => {
23
+ const { window_type, form_target, width, height } = window.ENV;
24
+
25
+ const successMessageEl = getRelaunchElement("success");
26
+ const retryMessageEl = getRelaunchElement("retry");
27
+ const relaunchFormEl = getRelaunchElement("form");
28
+ const retryButtonEl = getRelaunchElement("request");
29
+
30
+ // set a test cookie, both with and without same-site none
31
+ // this will work for local development and deployed environments
32
+ document.cookie = `${COOKIE_NAME}=true; path=/;`;
33
+ document.cookie = `${COOKIE_NAME}=true; path=/; samesite=none; secure`;
34
+ if (
35
+ document.cookie.split(";").some((cookie) => cookie.includes(COOKIE_NAME))
36
+ ) {
37
+ // setting cookies works, remove test cookie and continue login flow inline
38
+ document.cookie = `${COOKIE_NAME}=; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT`;
39
+ document.getElementById("redirect-form").submit();
40
+ return;
41
+ }
42
+
43
+ // open in new tab and restart login flow
44
+ show(successMessageEl);
45
+ const newWindow = openNewWindow({ window_type, form_target, width, height });
46
+
47
+ if (newWindow) {
48
+ // tool opened successfully, POST login form data to opened window
49
+ relaunchFormEl.submit();
50
+ return;
51
+ }
52
+
53
+ // tool open blocked, tell user to allow popups and try again
54
+ show(retryMessageEl);
55
+ hide(successMessageEl);
56
+
57
+ retryButtonEl.addEventListener("click", () => {
58
+ // open tool and POST login data again
59
+ openNewWindow({ window_type, form_target, width, height });
60
+ relaunchFormEl.submit();
61
+
62
+ show(successMessageEl);
63
+ hide(retryMessageEl);
64
+ });
65
+ };
66
+
67
+ // run on page load
68
+ document.addEventListener("DOMContentLoaded", relaunchOnLogin);
69
+
70
+ // exported for testing only
71
+ module.exports = {
72
+ COOKIE_NAME,
73
+ openNewWindow,
74
+ hide,
75
+ show,
76
+ relaunchOnLogin,
77
+ };
@@ -0,0 +1,52 @@
1
+ .flex-container {
2
+ display: none;
3
+ flex-direction: column;
4
+ height: 100%;
5
+ font-family: "Segoe UI", Frutiger, "Frutiger Linotype", "Dejavu Sans",
6
+ "Helvetica Neue", Arial, sans-serif;
7
+ font-size: 1.1rem;
8
+ padding: 0 24px;
9
+ }
10
+
11
+ .flex-item {
12
+ margin-bottom: 10px;
13
+ text-align: center;
14
+ }
15
+
16
+ .flex-container div:first-child {
17
+ margin-top: 24px;
18
+ }
19
+
20
+ p {
21
+ margin: 0 0 0.5em 0;
22
+ }
23
+
24
+ button {
25
+ background: #008ee2;
26
+ color: #ffffff;
27
+ border: 1px solid;
28
+ border-color: #0079c1;
29
+ border-radius: 3px;
30
+ transition: background-color 0.2s ease-in-out;
31
+ display: inline-block;
32
+ position: relative;
33
+ padding: 8px 14px;
34
+ margin-bottom: 0;
35
+ font-size: 16px;
36
+ font-size: 1rem;
37
+ line-height: 20px;
38
+ text-align: center;
39
+ vertical-align: middle;
40
+ cursor: pointer;
41
+ text-decoration: none;
42
+ overflow: hidden;
43
+ text-shadow: none;
44
+ -webkit-user-select: none;
45
+ -moz-user-select: none;
46
+ user-select: none;
47
+ }
48
+
49
+ img {
50
+ width: 100px;
51
+ height: 100px;
52
+ }
@@ -0,0 +1,84 @@
1
+ module CanvasLtiThirdPartyCookies::RelaunchOnLogin
2
+ extend ActiveSupport::Concern
3
+
4
+ # this should replace your previous login render call, at the end of your login action.
5
+ #
6
+ # `redirect_url` (required): the authorization redirect URL to continue the login flow
7
+ #
8
+ # `redirect_data` (required): all form data required for the authorization redirect, which
9
+ # the previous login render call should have included in a form tag.
10
+ #
11
+ # `window_type`: (optional) Set to `:new_window` to open the tool in a
12
+ # new tab or window, or to `:popup` to open in a popup window.
13
+ # Defaults to `:new_window`.
14
+ #
15
+ # `width`: (optional) The width the popup window should be, in px. User
16
+ # has the discretion to ignore this. Only valid with window_type: popup.
17
+ # Defaults to 800px.
18
+ #
19
+ # `height`: (optional) The height the popup window should be, in px. User
20
+ # has the discretion to ignore this. Only valid with window_type: popup.
21
+ # Defaults to 600px.
22
+ #
23
+ # example:
24
+ # include CanvasLtiThirdPartyCookies::RelaunchOnLogin
25
+ # ...
26
+ # def login
27
+ # state, nonce = create_and_cache_state # handled elsewhere
28
+ # redirect_url = 'http://canvas.instructure.com/api/lti/authorize_redirect'
29
+ # redirect_data = {
30
+ # scope: 'openid',
31
+ # response_type: 'id_token',
32
+ # response_mode: 'form_post',
33
+ # prompt: 'none',
34
+ # redirect_uri: redirect_uri, # the launch url of the tool
35
+ # client_id: params.require(:client_id),
36
+ # login_hint: params.require(:login_hint),
37
+ # lti_message_hint: params.require(:lti_message_hint),
38
+ # state: state,
39
+ # nonce: nonce
40
+ # }
41
+ #
42
+ # relaunch_on_login(redirect_url, redirect_data)
43
+ # end
44
+ def relaunch_on_login(redirect_url, redirect_data, window_type: :new_window, width: 800, height: 600)
45
+ raise ArgumentError.new("window_type must be either :new_window or :popup") unless [:new_window, :popup].include? window_type
46
+
47
+ I18n.locale = calculate_locale
48
+ form_target = 'login_relaunch'
49
+ render(
50
+ 'canvas_lti_third_party_cookies/relaunch_on_login',
51
+ locals: {
52
+ redirect_url: redirect_url,
53
+ redirect_data: redirect_data,
54
+ relaunch_url: request.url,
55
+ relaunch_data: params.permit(:canvas_region, :client_id, :iss, :login_hint, :lti_message_hint, :target_link_uri),
56
+ form_target: form_target,
57
+ window_type: window_type,
58
+ js_env: {
59
+ form_target: form_target,
60
+ window_type: window_type,
61
+ width: width,
62
+ height: height
63
+ }
64
+ }
65
+ )
66
+ end
67
+
68
+ def calculate_locale
69
+ decoded_jwt = params[:lti_message_hint] ? JSON::JWT.decode(params[:lti_message_hint], :skip_verification) : {}
70
+ if decoded_jwt['canvas_locale']
71
+ # this is essentially the same logic as language_region_compatible_from below
72
+ # example: 'en-AU', 'da-x-k12', 'ru', 'zh-Hant'
73
+ full_locale = decoded_jwt['canvas_locale'].to_sym
74
+ return full_locale if I18n.available_locales.include?(full_locale)
75
+
76
+ # The exact locale is not available, let's trim it down if possible
77
+ # example: 'en', 'da', 'ru', 'zh'
78
+ trimmed_locale = decoded_jwt['canvas_locale'][0..1].to_sym
79
+ return trimmed_locale if I18n.available_locales.include?(trimmed_locale)
80
+ end
81
+
82
+ http_accept_language.language_region_compatible_from(I18n.available_locales) || I18n.default_locale
83
+ end
84
+ end
@@ -0,0 +1,11 @@
1
+ <div id="<%= id %>" class="flex-container">
2
+ <div class="flex-item">
3
+ <%= image_tag "canvas_lti_third_party_cookies/cookies.svg" %>
4
+ </div>
5
+
6
+ <div class="flex-item">
7
+ <h3><%= I18n.t "It looks like your browser doesn't like cookies." %></h3>
8
+ </div>
9
+
10
+ <%= yield %>
11
+ </div>
@@ -0,0 +1,49 @@
1
+ <%= stylesheet_link_tag 'canvas_lti_third_party_cookies/relaunch_on_login' %>
2
+ <%= javascript_tag do %>
3
+ window.ENV = <%= js_env.to_json.html_safe %>
4
+ <% end %>
5
+ <%= javascript_include_tag 'canvas_lti_third_party_cookies/relaunch_on_login' %>
6
+
7
+ <%# when submitted, continue to login flow step 2: redirect back to Canvas for authentication %>
8
+ <%= form_tag(redirect_url, method: :post, id: 'redirect-form') do %>
9
+ <% redirect_data.each do |k, v| %>
10
+ <%= hidden_field_tag(k, v) %>
11
+ <% end %>
12
+ <% end %>
13
+
14
+ <%# when submitted, replay login flow step 1 in a new window %>
15
+ <%# the target attribute tells the form to POST in the window matching that target, which is opened below %>
16
+ <%= form_tag(relaunch_url, method: :post, id: 'relaunch-form', target: form_target) do %>
17
+ <% relaunch_data.each do |k, v| %>
18
+ <%= hidden_field_tag(k, v) %>
19
+ <% end %>
20
+ <% end %>
21
+
22
+ <%# default message to display on new window launch; hidden by default %>
23
+ <%= render 'canvas_lti_third_party_cookies/cookie_message', id: 'relaunch-success' do %>
24
+ <div class="flex-item">
25
+ <p><%= I18n.t "Some browsers block third-party cookies by default, which this app relies on for launch and sign-in features." %></p>
26
+ <p><%= window_type == :new_window ?
27
+ I18n.t("This app has opened in a new tab, so that it can set its own cookies.") :
28
+ I18n.t("This app has opened in a popup window, so that it can set its own cookies.")
29
+ %></p>
30
+ </div>
31
+ <% end %>
32
+
33
+ <%# message displayed when popups are blocked; hidden by default %>
34
+ <%= render 'canvas_lti_third_party_cookies/cookie_message', id: 'relaunch-retry' do %>
35
+ <div class="flex-item">
36
+ <p><%= I18n.t "Some browsers block third-party cookies by default, which this app relies on for launch and sign-in features." %></p>
37
+ <p><%= window_type == :new_window ?
38
+ I18n.t("Launching this app in a new tab, separate from Canvas, will allow the app to set its own cookies.") :
39
+ I18n.t("Launching this app in a popup window, separate from Canvas, will allow the app to set its own cookies.")
40
+ %></p>
41
+ <p><%= I18n.t "To open the app, make sure your browser allows popups for this page and try again." %></p>
42
+ </div>
43
+
44
+ <div class="flex-item">
45
+ <button id="relaunch-request">
46
+ <%= window_type == :new_window ? I18n.t("Open in New Tab") : I18n.t("Open in Popup Window")%>
47
+ </button>
48
+ </div>
49
+ <% end %>
@@ -1,5 +1,23 @@
1
+ require 'i18nliner'
2
+ require 'http_accept_language'
3
+ require 'json/jwt'
4
+
1
5
  module CanvasLtiThirdPartyCookies
2
6
  class Engine < ::Rails::Engine
3
7
  isolate_namespace CanvasLtiThirdPartyCookies
8
+
9
+ initializer "CanvasLtiThirdPartyCookies.assets.precompile" do |app|
10
+ app.config.assets.precompile += %w(
11
+ canvas_lti_third_party_cookies/relaunch_on_login.js
12
+ canvas_lti_third_party_cookies/relaunch_on_login.css
13
+ canvas_lti_third_party_cookies/cookies.svg
14
+ )
15
+ end
16
+
17
+ initializer "CanvasLtiThirdPartyCookies.i18n.locale" do |app|
18
+ app.config.i18n.load_path += Dir[root.join('config','locales','*.yml')]
19
+ app.config.i18n.default_locale = :en
20
+ app.config.i18n.fallbacks = true
21
+ end
4
22
  end
5
23
  end
@@ -1,3 +1,3 @@
1
1
  module CanvasLtiThirdPartyCookies
2
- VERSION = '0.3.3'
2
+ VERSION = '1.0.1'
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: canvas_lti_third_party_cookies
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.3
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Xander Moffatt
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-03-10 00:00:00.000000000 Z
11
+ date: 2022-03-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -16,40 +16,62 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: 6.0.2
19
+ version: '6.1'
20
20
  - - ">="
21
21
  - !ruby/object:Gem::Version
22
- version: 6.0.2.1
22
+ version: 6.1.4.2
23
23
  type: :runtime
24
24
  prerelease: false
25
25
  version_requirements: !ruby/object:Gem::Requirement
26
26
  requirements:
27
27
  - - "~>"
28
28
  - !ruby/object:Gem::Version
29
- version: 6.0.2
29
+ version: '6.1'
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: 6.0.2.1
32
+ version: 6.1.4.2
33
33
  - !ruby/object:Gem::Dependency
34
- name: browser
34
+ name: i18nliner
35
35
  requirement: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '2.6'
40
- - - ">="
39
+ version: 0.1.2
40
+ type: :runtime
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: 0.1.2
47
+ - !ruby/object:Gem::Dependency
48
+ name: json-jwt
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
41
52
  - !ruby/object:Gem::Version
42
- version: 2.6.1
53
+ version: 1.13.0
43
54
  type: :runtime
44
55
  prerelease: false
45
56
  version_requirements: !ruby/object:Gem::Requirement
46
57
  requirements:
47
58
  - - "~>"
48
59
  - !ruby/object:Gem::Version
49
- version: '2.6'
50
- - - ">="
60
+ version: 1.13.0
61
+ - !ruby/object:Gem::Dependency
62
+ name: http_accept_language
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: 2.1.1
68
+ type: :runtime
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
51
73
  - !ruby/object:Gem::Version
52
- version: 2.6.1
74
+ version: 2.1.1
53
75
  - !ruby/object:Gem::Dependency
54
76
  name: sqlite3
55
77
  requirement: !ruby/object:Gem::Requirement
@@ -75,9 +97,12 @@ files:
75
97
  - LICENSE
76
98
  - README.md
77
99
  - Rakefile
78
- - app/controllers/concerns/canvas_lti_third_party_cookies/safari_launch.rb
79
- - app/views/canvas_lti_third_party_cookies/full_window_launch.erb
80
- - app/views/canvas_lti_third_party_cookies/request_storage_access.erb
100
+ - app/assets/images/canvas_lti_third_party_cookies/cookies.svg
101
+ - app/assets/javascripts/canvas_lti_third_party_cookies/relaunch_on_login.js
102
+ - app/assets/stylesheets/canvas_lti_third_party_cookies/relaunch_on_login.css
103
+ - app/controllers/concerns/canvas_lti_third_party_cookies/relaunch_on_login.rb
104
+ - app/views/canvas_lti_third_party_cookies/_cookie_message.erb
105
+ - app/views/canvas_lti_third_party_cookies/relaunch_on_login.erb
81
106
  - app/views/layouts/application.html.erb
82
107
  - lib/canvas_lti_third_party_cookies.rb
83
108
  - lib/canvas_lti_third_party_cookies/engine.rb
@@ -101,7 +126,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
101
126
  - !ruby/object:Gem::Version
102
127
  version: '0'
103
128
  requirements: []
104
- rubygems_version: 3.0.1
129
+ rubygems_version: 3.2.32
105
130
  signing_key:
106
131
  specification_version: 4
107
132
  summary: Allow LTI tools launched by Canvas to set 3rd party cookies in Safari 13.1+
@@ -1,76 +0,0 @@
1
- require 'browser'
2
-
3
- module CanvasLtiThirdPartyCookies::SafariLaunch
4
- extend ActiveSupport::Concern
5
-
6
- # this needs to be called as a before_action on the route that launches the tool
7
- # and the tool is required to pass some parameters to this method.
8
- # the `launch_url` parameter is required, which should be the route
9
- # that launches the tool.
10
- # the `launch_params` parameter is optional, and should contain
11
- # all needed query parameters that the tool requires to launch.
12
- # the `launch_data` parameter is optional, and should contain
13
- # all needed form data that the tool requires to launch.
14
- # example:
15
- # include CanvasLtiThirdPartyCookies::SafariLaunch
16
- # ...
17
- # before_action -> {
18
- # handle_safari_launch(launch_url: action_url, launch_params: { foo: bar }, launch_data: { foo: baz })
19
- # }
20
- def handle_safari_launch(launch_url:, launch_params: {}, launch_data: {})
21
- return unless is_safari?
22
-
23
- # Safari launch #4: Storage Access has been granted,
24
- # so launch the app normally. Note that this is not an actual LTI launch, but
25
- # just opaquely passing on the data from launch #3.
26
- return if params[:storage_access_status].present?
27
-
28
- # Safari launch #2: Full-window launch, solely for first-party user interaction.
29
- # During a full-window launch, Canvas provides a :platform_redirect_url that
30
- # will launch the tool again within an iframe in Canvas. (#3)
31
- if params[:platform_redirect_url].present?
32
- return render(
33
- 'canvas_lti_third_party_cookies/full_window_launch',
34
- locals: { platform_redirect_url: params[:platform_redirect_url] }
35
- )
36
- end
37
-
38
- # Safari launch #1: request Storage Access, then relaunch the tool. (#4)
39
- # If request fails, request a full window launch instead. (#2)
40
- # Safari launch #3: Relaunched by Canvas after full-window launch,
41
- # request Storage Access and then relaunch the tool. (#4)
42
- # Pass along any parameters provided by the tool that are needed to launch correctly,
43
- # and tell the tool that it has Storage Access.
44
- render(
45
- 'canvas_lti_third_party_cookies/request_storage_access',
46
- locals: {
47
- launch_url: launch_url,
48
- relaunch_url: relaunch_url(launch_url, launch_params),
49
- launch_data: launch_data.merge({ storage_access_status: "granted"})
50
- }
51
- )
52
- end
53
-
54
- # Safari launch #4 (described above) is actually an internal opaque redirect of launch #3
55
- # and not a real Canvas LTI launch, so the id_token (and specifically the nonce inside)
56
- # is exactly the same. Normally, ignoring the nonce is a Bad Idea since it can allow
57
- # replay attacks, but for this specific situation (the request is an internal redirect)
58
- # it's a sufficient hack.
59
- def should_ignore_nonce?
60
- referer = URI.parse(request.referer)
61
- is_safari? && params[:storage_access_status] == "granted" && referer.host == request.host && referer.port == request.port
62
- end
63
-
64
- private
65
-
66
- def is_safari?
67
- browser = Browser.new(request.headers["User-Agent"])
68
- # detect both MacOS and iOS Safari
69
- browser.safari? || (browser.webkit? && browser.platform.ios?)
70
- end
71
-
72
- def relaunch_url(launch_url, launch_params)
73
- return launch_url if launch_params.empty?
74
- "#{launch_url}?#{launch_params.to_query}"
75
- end
76
- end
@@ -1,80 +0,0 @@
1
- <%= javascript_tag do -%>
2
- document.addEventListener("DOMContentLoaded", () => {
3
- document.getElementById("redirect").addEventListener("click", () => {
4
- window.location.replace("<%= platform_redirect_url %>");
5
- });
6
- });
7
- <% end %>
8
- <style type="text/css">
9
- .flex-container {
10
- display: flex;
11
- flex-direction: column;
12
- height: 100%;
13
- font-family: "Segoe UI", Frutiger, "Frutiger Linotype", "Dejavu Sans", "Helvetica Neue", Arial, sans-serif;
14
- font-size: 1.1em;
15
- padding: 0 75px 0 75px;
16
- }
17
-
18
- .flex-item {
19
- margin-bottom: 10px;
20
- text-align: center;
21
- }
22
-
23
- .first {
24
- margin-top: 50px;
25
- }
26
-
27
- p {
28
- margin: 0 0 0.5em 0;
29
- }
30
-
31
- button {
32
- background: #008EE2;
33
- color: #ffffff;
34
- border: 1px solid;
35
- border-color: #0079C1;
36
- border-radius: 3px;
37
- transition: background-color 0.2s ease-in-out;
38
- display: inline-block;
39
- position: relative;
40
- padding: 8px 14px;
41
- margin-bottom: 0;
42
- font-size: 16px;
43
- font-size: 1rem;
44
- line-height: 20px;
45
- text-align: center;
46
- vertical-align: middle;
47
- cursor: pointer;
48
- text-decoration: none;
49
- overflow: hidden;
50
- text-shadow: none;
51
- -webkit-user-select: none;
52
- -moz-user-select: none;
53
- }
54
-
55
- #safari-logo {
56
- width: 100px;
57
- height: 100px;
58
- }
59
- </style>
60
-
61
- <div class="flex-container">
62
- <div class="flex-item first">
63
- <img id="safari-logo" src="https://upload.wikimedia.org/wikipedia/commons/5/52/Safari_browser_logo.svg" alt="Safari Logo" />
64
- </div>
65
-
66
- <div class="flex-item">
67
- <strong>It looks like you are using Safari.</strong>
68
- </div>
69
-
70
- <div class="flex-item">
71
- <p>Occasionally, Safari requires you to launch this app outside of Canvas before logging in.</p>
72
- <p>This setup is now complete, and Canvas can now relaunch this app.</p>
73
- <p>In some cases, you may need to relaunch this app yourself.</p>
74
- </div>
75
-
76
- <div class="flex-item">
77
- <button id="redirect">Relaunch App in Canvas</button>
78
- </div>
79
- <div>
80
- </div>