atomic_lti 1.1.0

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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +22 -0
  4. data/Rakefile +13 -0
  5. data/app/assets/config/atomic_lti_manifest.js +1 -0
  6. data/app/assets/stylesheets/atomic_lti/application.css +0 -0
  7. data/app/assets/stylesheets/atomic_lti/jwks.css +4 -0
  8. data/app/controllers/atomic_lti/jwks_controller.rb +17 -0
  9. data/app/helpers/atomic_lti/launch_helper.rb +4 -0
  10. data/app/jobs/atomic_lti/application_job.rb +4 -0
  11. data/app/lib/atomic_lti/auth_token.rb +35 -0
  12. data/app/lib/atomic_lti/authorization.rb +152 -0
  13. data/app/lib/atomic_lti/config.rb +213 -0
  14. data/app/lib/atomic_lti/deep_linking.rb +36 -0
  15. data/app/lib/atomic_lti/definitions.rb +169 -0
  16. data/app/lib/atomic_lti/exceptions.rb +87 -0
  17. data/app/lib/atomic_lti/lti.rb +94 -0
  18. data/app/lib/atomic_lti/open_id.rb +22 -0
  19. data/app/lib/atomic_lti/params.rb +135 -0
  20. data/app/lib/atomic_lti/services/base.rb +38 -0
  21. data/app/lib/atomic_lti/services/line_items.rb +90 -0
  22. data/app/lib/atomic_lti/services/names_and_roles.rb +74 -0
  23. data/app/lib/atomic_lti/services/results.rb +18 -0
  24. data/app/lib/atomic_lti/services/score.rb +69 -0
  25. data/app/lib/atomic_lti/services/score_canvas.rb +47 -0
  26. data/app/mailers/atomic_lti/application_mailer.rb +6 -0
  27. data/app/models/atomic_lti/application_record.rb +5 -0
  28. data/app/models/atomic_lti/context.rb +10 -0
  29. data/app/models/atomic_lti/deployment.rb +13 -0
  30. data/app/models/atomic_lti/install.rb +11 -0
  31. data/app/models/atomic_lti/jwk.rb +41 -0
  32. data/app/models/atomic_lti/oauth_state.rb +5 -0
  33. data/app/models/atomic_lti/open_id_state.rb +5 -0
  34. data/app/models/atomic_lti/platform.rb +5 -0
  35. data/app/models/atomic_lti/platform_instance.rb +8 -0
  36. data/app/views/atomic_lti/launches/index.html.erb +11 -0
  37. data/app/views/atomic_lti/shared/redirect.html.erb +15 -0
  38. data/app/views/layouts/atomic_lti/application.html.erb +14 -0
  39. data/config/routes.rb +3 -0
  40. data/db/migrate/20220428175127_create_atomic_lti_platforms.rb +12 -0
  41. data/db/migrate/20220428175128_create_atomic_lti_platform_instances.rb +15 -0
  42. data/db/migrate/20220428175247_create_atomic_lti_installs.rb +11 -0
  43. data/db/migrate/20220428175305_create_atomic_lti_deployments.rb +13 -0
  44. data/db/migrate/20220428175336_create_atomic_lti_contexts.rb +15 -0
  45. data/db/migrate/20220428175423_create_atomic_lti_oauth_states.rb +10 -0
  46. data/db/migrate/20220503003528_create_atomic_lti_jwks.rb +12 -0
  47. data/db/migrate/20221010140920_create_open_id_state.rb +9 -0
  48. data/db/seeds.rb +29 -0
  49. data/lib/atomic_lti/engine.rb +9 -0
  50. data/lib/atomic_lti/error_handling_middleware.rb +33 -0
  51. data/lib/atomic_lti/open_id_middleware.rb +270 -0
  52. data/lib/atomic_lti/version.rb +3 -0
  53. data/lib/atomic_lti.rb +27 -0
  54. data/lib/tasks/atomic_lti_tasks.rake +4 -0
  55. metadata +129 -0
@@ -0,0 +1,270 @@
1
+ module AtomicLti
2
+ class OpenIdMiddleware
3
+ def initialize(app)
4
+ @app = app
5
+ end
6
+
7
+ def init_paths
8
+ [
9
+ AtomicLti.oidc_init_path
10
+ ]
11
+ end
12
+
13
+ def redirect_paths
14
+ [
15
+ AtomicLti.oidc_redirect_path
16
+ ]
17
+ end
18
+
19
+ def handle_init(request)
20
+ nonce = SecureRandom.hex(64)
21
+
22
+ redirect_uri = [request.base_url, AtomicLti.oidc_redirect_path].join
23
+
24
+ state = AtomicLti::OpenId.state
25
+ url = build_oidc_response(request, state, nonce, redirect_uri)
26
+
27
+ headers = { "Location" => url, "Content-Type" => "text/html" }
28
+ Rack::Utils.set_cookie_header!(headers, "open_id_state", state)
29
+ [302, headers, ["Found"]]
30
+ end
31
+
32
+ def handle_redirect(request)
33
+ raise AtomicLti::Exceptions::NoLTIToken if request.params["id_token"].blank?
34
+
35
+ lti_token = AtomicLti::Authorization.validate_token(
36
+ request.params["id_token"],
37
+ )
38
+
39
+ AtomicLti::Lti.validate!(lti_token)
40
+
41
+ uri = URI(request.url)
42
+ # Technically the target_link_uri is not required and the certification suite
43
+ # does not send it on a deep link launch. Typically target link uri will be present
44
+ # but at least for the certification suite we have to have a backup default
45
+ # value that can be set in the configuration of Atomic LTI using
46
+ # the default_deep_link_path
47
+ target_link_uri = lti_token[AtomicLti::Definitions::TARGET_LINK_URI_CLAIM] ||
48
+ File.join("#{uri.scheme}://#{uri.host}", AtomicLti.default_deep_link_path)
49
+
50
+ redirect_params = {
51
+ state: request.params["state"],
52
+ id_token: request.params["id_token"],
53
+ }
54
+ html = ApplicationController.renderer.render(
55
+ :html,
56
+ layout: false,
57
+ template: "atomic_lti/shared/redirect",
58
+ assigns: { launch_params: redirect_params, launch_url: target_link_uri },
59
+ )
60
+
61
+ [200, { "Content-Type" => "text/html" }, [html]]
62
+ end
63
+
64
+ def matches_redirect?(request)
65
+ raise AtomicLti::Exceptions::ConfigurationError.new("AtomicLti.oidc_redirect_path is not configured") if AtomicLti.oidc_redirect_path.blank?
66
+ redirect_uri = URI.parse(AtomicLti.oidc_redirect_path)
67
+ redirect_path_params = if redirect_uri.query
68
+ CGI.parse(redirect_uri.query)
69
+ else
70
+ []
71
+ end
72
+
73
+ matches_redirect_path = request.path == redirect_uri.path
74
+
75
+ return false if !matches_redirect_path
76
+
77
+ params_match = redirect_path_params.all? { |key, values| request.params[key] == values.first }
78
+
79
+ matches_redirect_path && params_match
80
+ end
81
+
82
+ def matches_target_link?(request)
83
+ AtomicLti.target_link_path_prefixes.any? do |prefix|
84
+ request.path.starts_with? prefix
85
+ end || request.path.starts_with?(AtomicLti.default_deep_link_path)
86
+ end
87
+
88
+ def handle_lti_launch(env, request)
89
+ id_token = request.params["id_token"]
90
+ state = request.params["state"]
91
+ url = request.url
92
+
93
+ payload = valid_token(state: state, id_token: id_token, url: url)
94
+ if payload
95
+ decoded_jwt = payload
96
+
97
+ update_install(id_token: decoded_jwt)
98
+ update_platform_instance(id_token: decoded_jwt)
99
+ update_deployment(id_token: decoded_jwt)
100
+ update_lti_context(id_token: decoded_jwt)
101
+
102
+ errors = decoded_jwt.dig(AtomicLti::Definitions::TOOL_PLATFORM_CLAIM, 'errors')
103
+ if errors.present? && !errors['errors'].empty?
104
+ Rails.logger.error("Detected errors in lti launch: #{errors}, id_token: #{id_token}")
105
+ end
106
+
107
+ env['atomic.validated.decoded_id_token'] = decoded_jwt
108
+ env['atomic.validated.id_token'] = id_token
109
+
110
+ @app.call(env)
111
+ else
112
+ Rails.logger.info("Invalid lti launch: id_token: #{payload} - id_token: #{id_token} - state: #{state} - url: #{url}")
113
+ [401, {}, ["Invalid Lti Launch"]]
114
+ end
115
+ end
116
+
117
+ def error!(body = "Error", status = 500, headers = {"Content-Type" => "text/html"})
118
+ [status, headers, [body]]
119
+ end
120
+
121
+ def call(env)
122
+ request = Rack::Request.new(env)
123
+ if init_paths.include?(request.path)
124
+ handle_init(request)
125
+ elsif matches_redirect?(request)
126
+ handle_redirect(request)
127
+ elsif matches_target_link?(request) && request.params["id_token"].present?
128
+ handle_lti_launch(env, request)
129
+ else
130
+ @app.call(env)
131
+ end
132
+ end
133
+
134
+ protected
135
+
136
+ def update_platform_instance(id_token:)
137
+ if id_token[AtomicLti::Definitions::TOOL_PLATFORM_CLAIM].present? && id_token.dig(AtomicLti::Definitions::TOOL_PLATFORM_CLAIM, 'guid').present?
138
+ name = id_token.dig(AtomicLti::Definitions::TOOL_PLATFORM_CLAIM, 'name')
139
+ version = id_token.dig(AtomicLti::Definitions::TOOL_PLATFORM_CLAIM, 'version')
140
+ product_family_code = id_token.dig(AtomicLti::Definitions::TOOL_PLATFORM_CLAIM, 'product_family_code')
141
+
142
+ AtomicLti::PlatformInstance.create_with(
143
+ name: name,
144
+ version: version,
145
+ product_family_code: product_family_code,
146
+ ).find_or_create_by!(
147
+ iss: id_token['iss'],
148
+ guid: id_token.dig(AtomicLti::Definitions::TOOL_PLATFORM_CLAIM, 'guid')
149
+ ).update!(
150
+ name: name,
151
+ version: version,
152
+ product_family_code: product_family_code,
153
+ )
154
+ else
155
+ Rails.logger.info("No platform guid recieved: #{id_token}")
156
+ end
157
+ end
158
+
159
+ def update_install(id_token:)
160
+ client_id = id_token["aud"]
161
+ iss = id_token["iss"]
162
+
163
+ if client_id.present? && iss.present?
164
+
165
+ AtomicLti::Install.find_or_create_by!(
166
+ iss: iss,
167
+ client_id: client_id
168
+ )
169
+ else
170
+ Rails.logger.info("No client_id recieved: #{id_token}")
171
+ end
172
+ end
173
+
174
+ def update_lti_context(id_token:)
175
+ if id_token[AtomicLti::Definitions::CONTEXT_CLAIM].present? && id_token[AtomicLti::Definitions::CONTEXT_CLAIM]['id'].present?
176
+ iss = id_token['iss']
177
+ deployment_id = id_token[AtomicLti::Definitions::DEPLOYMENT_ID]
178
+ context_id = id_token[AtomicLti::Definitions::CONTEXT_CLAIM]['id']
179
+ label = id_token[AtomicLti::Definitions::CONTEXT_CLAIM]['label']
180
+ title = id_token[AtomicLti::Definitions::CONTEXT_CLAIM]['title']
181
+ types = id_token[AtomicLti::Definitions::CONTEXT_CLAIM]['type']
182
+
183
+ AtomicLti::Context.create_with(
184
+ label: label,
185
+ title: title,
186
+ types: types,
187
+ ).find_or_create_by!(
188
+ iss: iss,
189
+ deployment_id: deployment_id,
190
+ context_id: context_id
191
+ ).update!(
192
+ label: label,
193
+ title: title,
194
+ types: types,
195
+ )
196
+ else
197
+ Rails.logger.info("No context claim recieved: #{id_token}")
198
+ end
199
+ end
200
+
201
+ def update_deployment(id_token:)
202
+ client_id = id_token["aud"]
203
+ iss = id_token["iss"]
204
+ deployment_id = id_token[AtomicLti::Definitions::DEPLOYMENT_ID]
205
+ platform_guid = id_token.dig(AtomicLti::Definitions::TOOL_PLATFORM_CLAIM, "guid")
206
+
207
+ Rails.logger.debug("Associating deployment: #{iss}/#{deployment_id} with client_id: iss: #{iss} / client_id: #{client_id} / platform_guid: #{platform_guid}")
208
+
209
+
210
+ AtomicLti::Deployment
211
+ .create_with(
212
+ client_id: client_id,
213
+ platform_guid: platform_guid
214
+ ).find_or_create_by!(
215
+ iss: iss,
216
+ deployment_id: deployment_id
217
+ ).update!(
218
+ client_id: client_id,
219
+ platform_guid: platform_guid
220
+ )
221
+ end
222
+
223
+ def valid_token(state:, id_token:, url:)
224
+ # Validate the state by checking the database for the nonce
225
+ valid_state = AtomicLti::OpenId.validate_open_id_state(state)
226
+
227
+ return false if !valid_state
228
+
229
+ token = false
230
+
231
+ begin
232
+ token = AtomicLti::Authorization.validate_token(id_token)
233
+ rescue JWT::DecodeError => e
234
+ Rails.logger.error("Unable to decode jwt: #{e}, #{e.backtrace}")
235
+ return false
236
+ end
237
+
238
+ return false if token.nil?
239
+
240
+ AtomicLti::Lti.validate!(token, url, true)
241
+
242
+ token
243
+ end
244
+
245
+ def build_oidc_response(request, state, nonce, redirect_uri)
246
+ platform = AtomicLti::Platform.find_by(iss: request.params["iss"])
247
+ if !platform
248
+ raise AtomicLti::Exceptions::NoLTIPlatform, "No LTI Platform found for iss #{request.params["iss"]}"
249
+ end
250
+
251
+ uri = URI.parse(platform.oidc_url)
252
+ uri_params = Rack::Utils.parse_query(uri.query)
253
+ auth_params = {
254
+ response_type: "id_token",
255
+ redirect_uri: redirect_uri,
256
+ response_mode: "form_post",
257
+ client_id: request.params["client_id"],
258
+ scope: "openid",
259
+ state: state,
260
+ login_hint: request.params["login_hint"],
261
+ prompt: "none",
262
+ lti_message_hint: request.params["lti_message_hint"],
263
+ nonce: nonce,
264
+ }.merge(uri_params)
265
+ uri.fragment = uri.query = nil
266
+
267
+ [uri.to_s, "?", auth_params.to_query].join
268
+ end
269
+ end
270
+ end
@@ -0,0 +1,3 @@
1
+ module AtomicLti
2
+ VERSION = '1.1.0'
3
+ end
data/lib/atomic_lti.rb ADDED
@@ -0,0 +1,27 @@
1
+ require "atomic_lti/version"
2
+ require "atomic_lti/engine"
3
+ require "atomic_lti/open_id_middleware"
4
+ require "atomic_lti/error_handling_middleware"
5
+ require_relative "../app/lib/atomic_lti/definitions"
6
+
7
+ module AtomicLti
8
+
9
+ # Set this to true to scope context_id's to the ISS rather than
10
+ # to the deployment id. We anticipate LMS's will work this
11
+ # way, and it means that reinstalling into a course won't change
12
+ # the context records.
13
+ mattr_accessor :context_scope_to_iss
14
+ @@context_scope_to_iss = true
15
+
16
+ mattr_accessor :oidc_init_path
17
+ mattr_accessor :oidc_redirect_path
18
+ mattr_accessor :target_link_path_prefixes
19
+ mattr_accessor :default_deep_link_path
20
+ mattr_accessor :jwt_secret
21
+ mattr_accessor :scopes
22
+
23
+ def self.get_deployments(iss:, deployment_ids:)
24
+ AtomicLti::Deployment.where(iss: iss, deployment_id: deployment_ids)
25
+ end
26
+
27
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :atomic_lti do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,129 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: atomic_lti
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Matt Petro
8
+ - Justin Ball
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2023-02-27 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rails
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - "~>"
19
+ - !ruby/object:Gem::Version
20
+ version: 7.0.3
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - "~>"
26
+ - !ruby/object:Gem::Version
27
+ version: 7.0.3
28
+ - !ruby/object:Gem::Dependency
29
+ name: pg
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - '='
33
+ - !ruby/object:Gem::Version
34
+ version: 1.3.5
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - '='
40
+ - !ruby/object:Gem::Version
41
+ version: 1.3.5
42
+ description: AtomicLti implements the LTI Advantage specification. This gem does contain
43
+ source code specific to other Atomic Jolt products
44
+ email:
45
+ - matt.petro@atomicjolt.com
46
+ - justin.ball@atomicjolt.com
47
+ executables: []
48
+ extensions: []
49
+ extra_rdoc_files: []
50
+ files:
51
+ - MIT-LICENSE
52
+ - README.md
53
+ - Rakefile
54
+ - app/assets/config/atomic_lti_manifest.js
55
+ - app/assets/stylesheets/atomic_lti/application.css
56
+ - app/assets/stylesheets/atomic_lti/jwks.css
57
+ - app/controllers/atomic_lti/jwks_controller.rb
58
+ - app/helpers/atomic_lti/launch_helper.rb
59
+ - app/jobs/atomic_lti/application_job.rb
60
+ - app/lib/atomic_lti/auth_token.rb
61
+ - app/lib/atomic_lti/authorization.rb
62
+ - app/lib/atomic_lti/config.rb
63
+ - app/lib/atomic_lti/deep_linking.rb
64
+ - app/lib/atomic_lti/definitions.rb
65
+ - app/lib/atomic_lti/exceptions.rb
66
+ - app/lib/atomic_lti/lti.rb
67
+ - app/lib/atomic_lti/open_id.rb
68
+ - app/lib/atomic_lti/params.rb
69
+ - app/lib/atomic_lti/services/base.rb
70
+ - app/lib/atomic_lti/services/line_items.rb
71
+ - app/lib/atomic_lti/services/names_and_roles.rb
72
+ - app/lib/atomic_lti/services/results.rb
73
+ - app/lib/atomic_lti/services/score.rb
74
+ - app/lib/atomic_lti/services/score_canvas.rb
75
+ - app/mailers/atomic_lti/application_mailer.rb
76
+ - app/models/atomic_lti/application_record.rb
77
+ - app/models/atomic_lti/context.rb
78
+ - app/models/atomic_lti/deployment.rb
79
+ - app/models/atomic_lti/install.rb
80
+ - app/models/atomic_lti/jwk.rb
81
+ - app/models/atomic_lti/oauth_state.rb
82
+ - app/models/atomic_lti/open_id_state.rb
83
+ - app/models/atomic_lti/platform.rb
84
+ - app/models/atomic_lti/platform_instance.rb
85
+ - app/views/atomic_lti/launches/index.html.erb
86
+ - app/views/atomic_lti/shared/redirect.html.erb
87
+ - app/views/layouts/atomic_lti/application.html.erb
88
+ - config/routes.rb
89
+ - db/migrate/20220428175127_create_atomic_lti_platforms.rb
90
+ - db/migrate/20220428175128_create_atomic_lti_platform_instances.rb
91
+ - db/migrate/20220428175247_create_atomic_lti_installs.rb
92
+ - db/migrate/20220428175305_create_atomic_lti_deployments.rb
93
+ - db/migrate/20220428175336_create_atomic_lti_contexts.rb
94
+ - db/migrate/20220428175423_create_atomic_lti_oauth_states.rb
95
+ - db/migrate/20220503003528_create_atomic_lti_jwks.rb
96
+ - db/migrate/20221010140920_create_open_id_state.rb
97
+ - db/seeds.rb
98
+ - lib/atomic_lti.rb
99
+ - lib/atomic_lti/engine.rb
100
+ - lib/atomic_lti/error_handling_middleware.rb
101
+ - lib/atomic_lti/open_id_middleware.rb
102
+ - lib/atomic_lti/version.rb
103
+ - lib/tasks/atomic_lti_tasks.rake
104
+ homepage: https://github.com/atomicjolt/atomic_lti
105
+ licenses:
106
+ - MIT
107
+ metadata:
108
+ homepage_uri: https://github.com/atomicjolt/atomic_lti
109
+ source_code_uri: https://github.com/atomicjolt/atomic_lti
110
+ post_install_message:
111
+ rdoc_options: []
112
+ require_paths:
113
+ - lib
114
+ required_ruby_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ required_rubygems_version: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ requirements: []
125
+ rubygems_version: 3.1.6
126
+ signing_key:
127
+ specification_version: 4
128
+ summary: AtomicLti implements the LTI Advantage specification.
129
+ test_files: []