canvas_lti_third_party_cookies 0.3.3 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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>