atomic_lti 1.3.1 → 1.5.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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.