atomic_lti 1.3.1 → 1.5.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.
@@ -1,15 +1,28 @@
1
1
  <!DOCTYPE html>
2
2
  <html lang="en">
3
3
  <head>
4
- <script type="text/javascript">
5
- window.onload=function(){document.forms[0].submit();};
6
- </script>
4
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet">
5
+ <%= stylesheet_link_tag "atomic_lti/launch" %>
7
6
  </head>
8
7
  <body>
9
- <form action="<%= @launch_url -%>" method="POST">
10
- <% @launch_params.each do |name, value| -%>
11
- <%= hidden_field_tag(name, value) %>
12
- <% end -%>
13
- </form>
8
+ <noscript>
9
+ <div class="u-flex aj-centered-message">
10
+ <i class="material-icons-outlined aj-icon" aria-hidden="true">warning</i>
11
+ <p class="aj-text">
12
+ You must have javascript enabled to use this application.
13
+ </p>
14
+ </div>
15
+ </noscript>
16
+ <form action="<%= @launch_url -%>" method="POST">
17
+ <% @launch_params.each do |name, value| -%>
18
+ <%= hidden_field_tag(name, value) %>
19
+ <% end -%>
20
+ </form>
21
+ </div>
22
+ <script>
23
+ window.addEventListener("load", () => {
24
+ document.forms[0].submit();
25
+ });
26
+ </script>
14
27
  </body>
15
28
  </html>
@@ -0,0 +1,6 @@
1
+ class AddStateToOpenIdState < ActiveRecord::Migration[7.0]
2
+ def change
3
+ add_column :atomic_lti_open_id_states, :state, :string
4
+ add_index :atomic_lti_open_id_states, :state, unique: true
5
+ end
6
+ end
data/db/seeds.rb CHANGED
@@ -2,16 +2,16 @@
2
2
  AtomicLti::Jwk.find_or_create_by(domain: nil)
3
3
 
4
4
  # Add some platforms
5
- AtomicLti::Platform.create_with(
6
- jwks_url: "https://canvas.instructure.com/api/lti/security/jwks",
7
- token_url: "https://canvas.instructure.com/login/oauth2/token",
8
- oidc_url: "https://canvas.instructure.com/api/lti/authorize_redirect"
5
+ AtomicLti::Platform.create_with(
6
+ jwks_url: AtomicLti::Definitions::CANVAS_PUBLIC_LTI_KEYS_URL,
7
+ token_url: AtomicLti::Definitions::CANVAS_AUTH_TOKEN_URL,
8
+ oidc_url: AtomicLti::Definitions::CANVAS_OIDC_URL,
9
9
  ).find_or_create_by(iss: "https://canvas.instructure.com")
10
10
 
11
11
  AtomicLti::Platform.create_with(
12
- jwks_url: "https://canvas-beta.instructure.com/api/lti/security/jwks",
13
- token_url: "https://canvas-beta.instructure.com/login/oauth2/token",
14
- oidc_url: "https://canvas-beta.instructure.com/api/lti/authorize_redirect",
12
+ jwks_url: AtomicLti::Definitions::CANVAS_BETA_PUBLIC_LTI_KEYS_URL,
13
+ token_url: AtomicLti::Definitions::CANVAS_BETA_AUTH_TOKEN_URL,
14
+ oidc_url: AtomicLti::Definitions::CANVAS_BETA_OIDC_URL,
15
15
  ).find_or_create_by(iss: "https://canvas-beta.instructure.com")
16
16
 
17
17
 
@@ -26,4 +26,4 @@ AtomicTenant::PinnedPlatformGuid.create(iss: "https://canvas.instructure.com", p
26
26
  # deployment_id: "21089:1f5e1ee417cb2b17f86a1232122452ab3f6188f7",
27
27
  # application_instance_id: 5,
28
28
  # created_at: Tue, 16 Aug 2022 16:05:20.848365000 UTC +00:00,
29
- # updated_at: Tue, 16 Aug 2022 16:05:20.848365000 UTC +00:00>
29
+ # updated_at: Tue, 16 Aug 2022 16:05:20.848365000 UTC +00:00>
@@ -4,7 +4,7 @@ module AtomicLti
4
4
  @app = app
5
5
  end
6
6
 
7
- def render_error(env, status, message)
7
+ def render_error(status, message)
8
8
  format = "text/plain"
9
9
  body = message
10
10
 
@@ -12,22 +12,30 @@ module AtomicLti
12
12
  end
13
13
 
14
14
  def render(status, body, format)
15
- [status,
16
- {
17
- "Content-Type" => "#{format}; charset=\"UTF-8\"",
18
- "Content-Length" => body.bytesize.to_s,
19
- },
20
- [body]]
15
+ [
16
+ status,
17
+ {
18
+ "Content-Type" => "#{format}; charset=\"UTF-8\"",
19
+ "Content-Length" => body.bytesize.to_s,
20
+ },
21
+ [body],
22
+ ]
21
23
  end
22
24
 
23
25
  def call(env)
24
26
  @app.call(env)
25
-
27
+ rescue JWT::ExpiredSignature
28
+ render_error(401, "The launch has expired. Please launch the application again.")
29
+ rescue JWT::DecodeError
30
+ render_error(401, "The launch token is invalid.")
31
+ rescue AtomicLti::Exceptions::NoLTIToken
32
+ render_error(401, "Invalid launch. Please launch the application again.")
33
+ rescue AtomicLti::Exceptions::AtomicLtiAuthException => e
34
+ render_error(401, "Invalid LTI launch. Please launch the application again. #{e.message}")
26
35
  rescue AtomicLti::Exceptions::AtomicLtiNotFoundException => e
27
- render_error(env, 404, e.message)
28
-
36
+ render_error(404, e.message)
29
37
  rescue AtomicLti::Exceptions::AtomicLtiException => e
30
- render_error(env, 500, e.message)
38
+ render_error(500, "Invalid LTI launch. Please launch the application again. #{e.message}")
31
39
  end
32
40
  end
33
41
  end
@@ -1,4 +1,8 @@
1
1
  module AtomicLti
2
+ # This is the same prefix used in the npm package. There's not a great way to share constants between ruby and npm.
3
+ # Don't change it unless you change it in the Javascript as well.
4
+ OPEN_ID_COOKIE_PREFIX = "open_id_".freeze
5
+
2
6
  class OpenIdMiddleware
3
7
  def initialize(app)
4
8
  @app = app
@@ -17,26 +21,86 @@ module AtomicLti
17
21
  end
18
22
 
19
23
  def handle_init(request)
20
- nonce = SecureRandom.hex(64)
24
+ platform = AtomicLti::Platform.find_by(iss: request.params["iss"])
25
+ if !platform
26
+ raise AtomicLti::Exceptions::NoLTIPlatform.new(iss: request.params["iss"])
27
+ end
28
+
29
+ nonce, state = AtomicLti::OpenId.generate_state
30
+
31
+ headers = { "Content-Type" => "text/html" }
32
+ Rack::Utils.set_cookie_header!(
33
+ headers, "#{OPEN_ID_COOKIE_PREFIX}storage",
34
+ { value: "1", path: "/", max_age: 365.days, http_only: false, secure: true, same_site: "None" }
35
+ )
36
+ Rack::Utils.set_cookie_header!(
37
+ headers, "#{OPEN_ID_COOKIE_PREFIX}#{state}",
38
+ { value: 1, path: "/", max_age: 1.minute, http_only: false, secure: true, same_site: "None" }
39
+ )
21
40
 
22
41
  redirect_uri = [request.base_url, AtomicLti.oidc_redirect_path].join
42
+ response_url = build_oidc_response(request, state, nonce, redirect_uri)
43
+
44
+ if request.cookies.present? || !AtomicLti.enforce_csrf_protection
45
+ # we know cookies will work, so redirect
46
+ headers["Location"] = response_url
23
47
 
24
- state = AtomicLti::OpenId.state
25
- url = build_oidc_response(request, state, nonce, redirect_uri)
48
+ [302, headers, ["Found"]]
49
+ else
50
+ # cookies might not work, so render our javascript form
51
+ if request.params["lti_storage_target"].present? && AtomicLti.use_post_message_storage
52
+ lti_storage_params = build_lti_storage_params(request, platform)
53
+ end
26
54
 
27
- headers = { "Location" => url, "Content-Type" => "text/html" }
28
- Rack::Utils.set_cookie_header!(headers, "open_id_state", state)
29
- [302, headers, ["Found"]]
55
+ html = ApplicationController.renderer.render(
56
+ :html,
57
+ layout: false,
58
+ template: "atomic_lti/shared/init",
59
+ assigns: {
60
+ settings: {
61
+ state: state,
62
+ responseUrl: response_url,
63
+ ltiStorageParams: lti_storage_params,
64
+ relaunchInitUrl: relaunch_init_url(request),
65
+ privacyPolicyUrl: AtomicLti.privacy_policy_url,
66
+ privacyPolicyMessage: AtomicLti.privacy_policy_message,
67
+ openIdCookiePrefix: OPEN_ID_COOKIE_PREFIX,
68
+ },
69
+ },
70
+ )
71
+
72
+ [200, headers, [html]]
73
+ end
30
74
  end
31
75
 
32
- def handle_redirect(request)
76
+ def validate_launch(request, validate_target_link_url)
77
+ # Validate and decode id_token
33
78
  raise AtomicLti::Exceptions::NoLTIToken if request.params["id_token"].blank?
34
79
 
35
- lti_token = AtomicLti::Authorization.validate_token(
36
- request.params["id_token"],
37
- )
80
+ id_token_decoded = AtomicLti::Authorization.validate_token(request.params["id_token"])
81
+
82
+ raise AtomicLti::Exceptions::InvalidLTIToken.new if id_token_decoded.nil?
83
+
84
+ # Validate id token contents
85
+ AtomicLti::Lti.validate!(id_token_decoded, request.url, validate_target_link_url)
86
+
87
+ # Check for the state cookie
88
+ state_verified = false
89
+ state = request.params["state"]
90
+ if request.cookies["open_id_#{state}"]
91
+ state_verified = true
92
+ end
38
93
 
39
- AtomicLti::Lti.validate!(lti_token)
94
+ # Validate the state and nonce
95
+ if !AtomicLti::OpenId.validate_state(id_token_decoded["nonce"], state)
96
+ raise AtomicLti::Exceptions::OpenIDStateError.new("Invalid OIDC state.")
97
+ end
98
+
99
+ [id_token_decoded, state, state_verified]
100
+ end
101
+
102
+ def handle_redirect(request)
103
+ id_token_decoded, _state, _state_verified = validate_launch(request, false)
40
104
 
41
105
  uri = URI(request.url)
42
106
  # Technically the target_link_uri is not required and the certification suite
@@ -44,25 +108,26 @@ module AtomicLti
44
108
  # but at least for the certification suite we have to have a backup default
45
109
  # value that can be set in the configuration of Atomic LTI using
46
110
  # the default_deep_link_path
47
- target_link_uri = lti_token[AtomicLti::Definitions::TARGET_LINK_URI_CLAIM] ||
111
+ target_link_uri = id_token_decoded[AtomicLti::Definitions::TARGET_LINK_URI_CLAIM] ||
48
112
  File.join("#{uri.scheme}://#{uri.host}", AtomicLti.default_deep_link_path)
49
113
 
50
- redirect_params = {
51
- state: request.params["state"],
52
- id_token: request.params["id_token"],
53
- }
54
114
  html = ApplicationController.renderer.render(
55
115
  :html,
56
116
  layout: false,
57
117
  template: "atomic_lti/shared/redirect",
58
- assigns: { launch_params: redirect_params, launch_url: target_link_uri },
118
+ assigns: {
119
+ launch_params: request.params,
120
+ launch_url: target_link_uri,
121
+ },
59
122
  )
60
123
 
61
124
  [200, { "Content-Type" => "text/html" }, [html]]
62
125
  end
63
126
 
64
127
  def matches_redirect?(request)
65
- raise AtomicLti::Exceptions::ConfigurationError.new("AtomicLti.oidc_redirect_path is not configured") if AtomicLti.oidc_redirect_path.blank?
128
+ if AtomicLti.oidc_redirect_path.blank?
129
+ raise AtomicLti::Exceptions::ConfigurationError.new("AtomicLti.oidc_redirect_path is not configured")
130
+ end
66
131
 
67
132
  redirect_uri = URI.parse(AtomicLti.oidc_redirect_path)
68
133
  redirect_path_params = if redirect_uri.query
@@ -87,35 +152,42 @@ module AtomicLti
87
152
  end
88
153
 
89
154
  def handle_lti_launch(env, request)
90
- id_token = request.params["id_token"]
91
- state = request.params["state"]
92
- url = request.url
155
+ id_token_decoded, state, state_verified = validate_launch(request, true)
93
156
 
94
- payload = valid_token(state: state, id_token: id_token, url: url)
95
- if payload
96
- decoded_jwt = payload
97
-
98
- update_install(id_token: decoded_jwt)
99
- update_platform_instance(id_token: decoded_jwt)
100
- update_deployment(id_token: decoded_jwt)
101
- update_lti_context(id_token: decoded_jwt)
157
+ id_token = request.params["id_token"]
158
+ update_install(id_token: id_token_decoded)
159
+ update_platform_instance(id_token: id_token_decoded)
160
+ update_deployment(id_token: id_token_decoded)
161
+ update_lti_context(id_token: id_token_decoded)
162
+
163
+ errors = id_token_decoded.dig(AtomicLti::Definitions::TOOL_PLATFORM_CLAIM, "errors")
164
+ if errors.present? && !errors["errors"].empty?
165
+ Rails.logger.error("Detected errors in lti launch: #{errors}, id_token: #{id_token}")
166
+ end
102
167
 
103
- errors = decoded_jwt.dig(AtomicLti::Definitions::TOOL_PLATFORM_CLAIM, "errors")
104
- if errors.present? && !errors["errors"].empty?
105
- Rails.logger.error("Detected errors in lti launch: #{errors}, id_token: #{id_token}")
106
- end
168
+ env["atomic.validated.decoded_id_token"] = id_token_decoded
169
+ env["atomic.validated.id_token"] = id_token
170
+
171
+ platform = AtomicLti::Platform.find_by!(iss: id_token_decoded["iss"])
172
+ if request.params["lti_storage_target"].present? && AtomicLti.use_post_message_storage
173
+ lti_storage_params = build_lti_storage_params(request, platform)
174
+ # Add the values needed to do client side validate to the environment
175
+ env["atomic.validated.state_validation"] = {
176
+ state: state,
177
+ lti_storage_params: lti_storage_params,
178
+ verified_by_cookie: state_verified,
179
+ }
180
+ end
107
181
 
108
- env["atomic.validated.decoded_id_token"] = decoded_jwt
109
- env["atomic.validated.id_token"] = id_token
182
+ @app.call(env)
110
183
 
111
- @app.call(env)
112
- else
113
- Rails.logger.info("Invalid lti launch: id_token: #{payload} - id_token: #{id_token} - state: #{state} - url: #{url}")
114
- [401, {}, ["Invalid Lti Launch"]]
115
- end
184
+ # Delete the state cookie
185
+ status, headers, body = @app.call(env)
186
+ # Rack::Utils.delete_cookie_header(headers, "#{OPEN_ID_COOKIE_PREFIX}#{state}")
187
+ [status, headers, body]
116
188
  end
117
189
 
118
- def error!(body = "Error", status = 500, headers = {"Content-Type" => "text/html"})
190
+ def error!(body = "Error", status = 500, headers = { "Content-Type" => "text/html" })
119
191
  [status, headers, [body]]
120
192
  end
121
193
 
@@ -134,6 +206,19 @@ module AtomicLti
134
206
 
135
207
  protected
136
208
 
209
+ def render_error(status, message)
210
+ html = ApplicationController.renderer.render(
211
+ :html,
212
+ layout: false,
213
+ template: "atomic_lti/shared/error",
214
+ assigns: {
215
+ message: message || "There was an error during the launch. Please try again.",
216
+ },
217
+ )
218
+
219
+ [status || 404, { "Content-Type" => "text/html" }, [html]]
220
+ end
221
+
137
222
  def update_platform_instance(id_token:)
138
223
  if id_token[AtomicLti::Definitions::TOOL_PLATFORM_CLAIM].present? &&
139
224
  id_token.dig(AtomicLti::Definitions::TOOL_PLATFORM_CLAIM, "guid").present?
@@ -222,32 +307,18 @@ module AtomicLti
222
307
  )
223
308
  end
224
309
 
225
- def valid_token(state:, id_token:, url:)
226
- # Validate the state by checking the database for the nonce
227
- valid_state = AtomicLti::OpenId.validate_open_id_state(state)
228
-
229
- return false if !valid_state
230
-
231
- token = false
232
-
233
- begin
234
- token = AtomicLti::Authorization.validate_token(id_token)
235
- rescue JWT::DecodeError => e
236
- Rails.logger.error("Unable to decode jwt: #{e}, #{e.backtrace}")
237
- return false
238
- end
239
-
240
- return false if token.nil?
241
-
242
- AtomicLti::Lti.validate!(token, url, true)
243
-
244
- token
310
+ def relaunch_init_url(request)
311
+ uri = URI.parse(request.url)
312
+ uri.fragment = uri.query = nil
313
+ params = request.params
314
+ params.delete("lti_storage_target")
315
+ [uri.to_s, "?", params.to_query].join
245
316
  end
246
317
 
247
318
  def build_oidc_response(request, state, nonce, redirect_uri)
248
319
  platform = AtomicLti::Platform.find_by(iss: request.params["iss"])
249
320
  if !platform
250
- raise AtomicLti::Exceptions::NoLTIPlatform.new(iss: request.params["iss"])
321
+ raise AtomicLti::Exceptions::NoLTIPlatform.new("No platform was found for iss: #{request.params['iss']}")
251
322
  end
252
323
 
253
324
  uri = URI.parse(platform.oidc_url)
@@ -268,5 +339,13 @@ module AtomicLti
268
339
 
269
340
  [uri.to_s, "?", auth_params.to_query].join
270
341
  end
342
+
343
+ def build_lti_storage_params(request, platform)
344
+ {
345
+ target: request.params["lti_storage_target"],
346
+ originSupportBroken: !AtomicLti.set_post_message_origin,
347
+ platformOIDCUrl: platform.oidc_url,
348
+ }
349
+ end
271
350
  end
272
351
  end
@@ -1,3 +1,3 @@
1
1
  module AtomicLti
2
- VERSION = '1.3.1'
2
+ VERSION = "1.5.1".freeze
3
3
  end
data/lib/atomic_lti.rb CHANGED
@@ -4,6 +4,7 @@ require "atomic_lti/open_id_middleware"
4
4
  require "atomic_lti/error_handling_middleware"
5
5
  require_relative "../app/lib/atomic_lti/definitions"
6
6
  require_relative "../app/lib/atomic_lti/exceptions"
7
+ require_relative "../app/lib/atomic_lti/role_enforcement_mode"
7
8
  module AtomicLti
8
9
 
9
10
  # Set this to true to scope context_id's to the ISS rather than
@@ -18,7 +19,33 @@ module AtomicLti
18
19
  mattr_accessor :target_link_path_prefixes
19
20
  mattr_accessor :default_deep_link_path
20
21
  mattr_accessor :jwt_secret
21
- mattr_accessor :scopes
22
+ mattr_accessor :scopes, default: AtomicLti::Definitions.scopes.join(" ")
23
+
24
+ # Set to true to enforce CSRF protection, either via cookies or postMessage
25
+ mattr_accessor :enforce_csrf_protection, default: true
26
+
27
+ # Set to true to use LTI postMessage storage for csrf token storage
28
+ # with this enabled we can operate without cookies
29
+ mattr_accessor :use_post_message_storage, default: true
30
+
31
+ # Set to true to set the targetOrigin on postMessage calls. The LTI spec
32
+ # requires this, but Canvas doesn't currently support it.
33
+ mattr_accessor :set_post_message_origin, default: false
34
+
35
+ mattr_accessor :privacy_policy_url, default: "#"
36
+ mattr_accessor :privacy_policy_message, default: nil
37
+
38
+ # https://www.imsglobal.org/spec/lti/v1p3#anonymous-launch-case
39
+ # 'anonymous' here means that the launch does not include a 'sub' field. In
40
+ # Canvas, this means the user is not logged in at all. If you enable this
41
+ # option, you will likely have to adjust application code to accommodate
42
+ mattr_accessor :allow_anonymous_user, default: false
43
+
44
+ # https://www.imsglobal.org/spec/lti/v1p3#role-vocabularies
45
+ # Determines how strictly to enforce the role vocabulary. The options are:
46
+ # - "DEFAULT" which means that unknown roles are allowed to be the only roles in the token.
47
+ # - "STRICT" which means that unknown roles are not allowed to be the only roles in the token.
48
+ mattr_accessor :role_enforcement_mode, default: AtomicLti::RoleEnforcementMode::DEFAULT
22
49
 
23
50
  def self.get_deployments(iss:, deployment_ids:)
24
51
  AtomicLti::Deployment.where(iss: iss, deployment_id: deployment_ids)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: atomic_lti
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.1
4
+ version: 1.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Petro
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2023-03-28 00:00:00.000000000 Z
13
+ date: 2023-08-17 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: pg
@@ -53,11 +53,15 @@ files:
53
53
  - MIT-LICENSE
54
54
  - README.md
55
55
  - Rakefile
56
+ - app/assets/builds/atomic_lti/init_app.js
57
+ - app/assets/builds/atomic_lti/init_app.js.map
56
58
  - app/assets/config/atomic_lti_manifest.js
57
59
  - app/assets/stylesheets/atomic_lti/application.css
58
60
  - app/assets/stylesheets/atomic_lti/jwks.css
61
+ - app/assets/stylesheets/atomic_lti/launch.css
59
62
  - app/controllers/atomic_lti/jwks_controller.rb
60
63
  - app/helpers/atomic_lti/launch_helper.rb
64
+ - app/javascript/atomic_lti/init_app.js
61
65
  - app/jobs/atomic_lti/application_job.rb
62
66
  - app/lib/atomic_lti/auth_token.rb
63
67
  - app/lib/atomic_lti/authorization.rb
@@ -68,6 +72,7 @@ files:
68
72
  - app/lib/atomic_lti/lti.rb
69
73
  - app/lib/atomic_lti/open_id.rb
70
74
  - app/lib/atomic_lti/params.rb
75
+ - app/lib/atomic_lti/role_enforcement_mode.rb
71
76
  - app/lib/atomic_lti/services/base.rb
72
77
  - app/lib/atomic_lti/services/line_items.rb
73
78
  - app/lib/atomic_lti/services/names_and_roles.rb
@@ -85,6 +90,8 @@ files:
85
90
  - app/models/atomic_lti/platform.rb
86
91
  - app/models/atomic_lti/platform_instance.rb
87
92
  - app/views/atomic_lti/launches/index.html.erb
93
+ - app/views/atomic_lti/shared/error.html.erb
94
+ - app/views/atomic_lti/shared/init.html.erb
88
95
  - app/views/atomic_lti/shared/redirect.html.erb
89
96
  - app/views/layouts/atomic_lti/application.html.erb
90
97
  - config/routes.rb
@@ -96,6 +103,7 @@ files:
96
103
  - db/migrate/20220428175423_create_atomic_lti_oauth_states.rb
97
104
  - db/migrate/20220503003528_create_atomic_lti_jwks.rb
98
105
  - db/migrate/20221010140920_create_open_id_state.rb
106
+ - db/migrate/20230726040941_add_state_to_open_id_state.rb
99
107
  - db/seeds.rb
100
108
  - lib/atomic_lti.rb
101
109
  - lib/atomic_lti/engine.rb
@@ -124,7 +132,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
124
132
  - !ruby/object:Gem::Version
125
133
  version: '0'
126
134
  requirements: []
127
- rubygems_version: 3.1.6
135
+ rubygems_version: 3.4.15
128
136
  signing_key:
129
137
  specification_version: 4
130
138
  summary: AtomicLti implements the LTI Advantage specification.