atomic_lti 1.1.0

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