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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1001c55b8a026812a60d322798ed5838ccfa9f9aebe538563b5f33befc7f339b
4
+ data.tar.gz: 2b19bd4852d7a04517677331951c2a0c9968c79d4e7121e0ffe07892e8f8474a
5
+ SHA512:
6
+ metadata.gz: fa00363f1e618f46db5c5bcb0a0efab5b5b4ebb470b316f3e3a10367e8e5b2dd6b7dca7309c17c59576a086367f99b8109ea7b9e6cc77252c98cfe9f312fa236
7
+ data.tar.gz: 4b537933b0208bdb34b88e893fd5817a6b5b21bea6a0ac6d2457ed7ba8ac6d71ecd721054e0638a2666c4d559973cf21ecfe05827bd5575bf36f50d956b70b1a
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2022 Matt Petro
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # AtomicLti
2
+ Atomic LTI implements the LTI Advantage specification.
3
+
4
+ ## Usage
5
+ Add the gem:
6
+
7
+ `gem 'atomic_lti', git: 'https://github.com/atomicjolt/atomic_lti.git', tag: '1.0.9'`
8
+
9
+ Add an initializer
10
+ `config/initializers/atomic_lti.rb`
11
+
12
+ with the following contents. Adjust paths as needed.
13
+
14
+ `
15
+ AtomicLti.oidc_init_path = "/oidc/init"
16
+ AtomicLti.oidc_redirect_path = "/oidc/redirect"
17
+ AtomicLti.target_link_path_prefixes = ["/lti_launches"]
18
+ AtomicLti.default_deep_link_path = "/lti_launches"
19
+ AtomicLti.jwt_secret = Rails.application.secrets.auth0_client_secret
20
+ AtomicLti.scopes = AtomicLti::Definitions.scopes.join(" ")
21
+ `
22
+
data/Rakefile ADDED
@@ -0,0 +1,13 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
9
+
10
+ require 'rspec/core/rake_task'
11
+
12
+ RSpec::Core::RakeTask.new(:spec)
13
+ task default: :spec
@@ -0,0 +1 @@
1
+ //= link_directory ../stylesheets/atomic_lti .css
File without changes
@@ -0,0 +1,4 @@
1
+ /*
2
+ Place all the styles related to the matching controller here.
3
+ They will automatically be included in application.css.
4
+ */
@@ -0,0 +1,17 @@
1
+ module AtomicLti
2
+ class JwksController < ::ApplicationController
3
+ def index
4
+ respond_to do |format|
5
+ # Map is required or the outer to_json will show your private keys to the world
6
+ format.json { render json: { keys: jwks_from_domain.map(&:to_json) }.to_json }
7
+ format.text { render plain: jwks_from_domain.map(&:to_pem).join('\n') }
8
+ end
9
+ end
10
+
11
+ protected
12
+
13
+ def jwks_from_domain
14
+ Jwk.where(domain: request.host_with_port).presence || Jwk.where(domain: nil)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,4 @@
1
+ module AtomicLti
2
+ module LaunchHelper
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module AtomicLti
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,35 @@
1
+ require "jwt"
2
+
3
+ module AtomicLti
4
+ module AuthToken
5
+
6
+ ALGORITHM = "HS512".freeze
7
+
8
+ # More information on jwt available at
9
+ # http://self-issued.info/docs/draft-ietf-oauth-json-web-token.html#rfc.section.4.1.6
10
+ def self.issue_token(payload, exp = 24.hours.from_now, secret = nil, aud = nil, header_fields = {})
11
+ payload["iat"] = DateTime.now.to_i # issued at claim
12
+ payload["exp"] = exp.to_i # Default expiration set to 24 hours.
13
+ payload["aud"] = aud || Rails.application.secrets.auth0_client_id
14
+ JWT.encode(
15
+ payload,
16
+ AtomicLti.jwt_secret,
17
+ ALGORITHM,
18
+ header_fields,
19
+ )
20
+ end
21
+
22
+ def self.valid?(token, secret = nil, algorithm = ALGORITHM)
23
+ decode(token, secret, true, algorithm)
24
+ end
25
+
26
+ def self.decode(token, secret = nil, validate = true, algorithm = ALGORITHM)
27
+ JWT.decode(
28
+ token,
29
+ AtomicLti.jwt_secret,
30
+ validate,
31
+ { algorithm: algorithm },
32
+ )
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,152 @@
1
+ require "jwt"
2
+
3
+ module AtomicLti
4
+ class Authorization
5
+
6
+ AUTHORIZATION_TRIES = 3
7
+ # Validates a token provided by an LTI consumer
8
+ def self.validate_token(token)
9
+ # Get the iss value from the original request during the oidc call.
10
+ # Use that value to figure out which jwk we should use.
11
+ decoded_token = JWT.decode(token, nil, false)
12
+
13
+ iss = decoded_token.dig(0, "iss")
14
+
15
+ raise AtomicLti::Exceptions::InvalidLTIToken.new("LTI token is missing iss") if iss.blank?
16
+
17
+ platform = Platform.find_by(iss: iss)
18
+
19
+ raise AtomicLti::Exceptions::NoLTIPlatform(iss: iss, deployment_id: decoded_token.dig(0, "deployment_id")) if platform.nil?
20
+
21
+ cache_key = "#{iss}_jwks"
22
+
23
+ jwk_loader = ->(options) do
24
+ jwks = Rails.cache.read(cache_key)
25
+ if options[:invalidate] || jwks.blank?
26
+ jwks = JSON.parse(
27
+ HTTParty.get(platform.jwks_url).body,
28
+ ).deep_symbolize_keys
29
+ Rails.cache.write(cache_key, jwks, expires_in: 12.hours)
30
+ end
31
+ jwks
32
+ end
33
+
34
+ lti_token, _keys = JWT.decode(token, nil, true, { algorithms: ["RS256"], jwks: jwk_loader })
35
+ lti_token
36
+ end
37
+
38
+ def self.sign_tool_jwt(payload)
39
+ jwk = Jwk.current_jwk
40
+ JWT.encode(payload, jwk.private_key, jwk.alg, kid: jwk.kid, typ: "JWT")
41
+ end
42
+
43
+ def self.client_assertion(iss:, deployment_id:)
44
+ # https://www.imsglobal.org/spec/lti/v1p3/#token-endpoint-claim-and-services
45
+ # When requesting an access token, the client assertion JWT iss and sub must both be the
46
+ # OAuth 2 client_id of the tool as issued by the learning platform during registration.
47
+ # Additional information:
48
+ # https://www.imsglobal.org/spec/security/v1p0/#using-json-web-tokens-with-oauth-2-0-client-credentials-grant
49
+
50
+ # lti_install = lti_deployment.lti_install
51
+
52
+ deployment = AtomicLti::Deployment.find_by(iss: iss, deployment_id: deployment_id)
53
+
54
+ raise AtomicLti::Exceptions::NoLTIDeployment.new(iss: iss, deployment_id: deployment_id) if deployment.nil?
55
+
56
+ install = deployment.install
57
+
58
+ raise AtomicLti::Exceptions::NoLTIInstall.new(iss: iss, deployment_id: deployment_id) if install.nil?
59
+
60
+ platform = install.platform
61
+
62
+ raise AtomicLti::Exceptions::NoLTIPlatform.new(iss: iss, deployment_id: deployment_id) if platform.nil?
63
+
64
+ payload = {
65
+ iss: install.client_id, # A unique identifier for the entity that issued the JWT
66
+ sub: install.client_id, # "client_id" of the OAuth Client
67
+ aud: platform.token_url, # Authorization server identifier
68
+ iat: Time.now.to_i, # Timestamp for when the JWT was created
69
+ exp: Time.now.to_i + 300, # Timestamp for when the JWT should be treated as having expired
70
+ # (after allowing a margin for clock skew)
71
+ jti: SecureRandom.hex(10), # A unique (potentially reusable) identifier for the token
72
+ }
73
+ sign_tool_jwt(payload)
74
+ end
75
+
76
+ def self.request_token(iss:, deployment_id:)
77
+ deployment = AtomicLti::Deployment.find_by(iss: iss, deployment_id: deployment_id)
78
+
79
+ raise AtomicLti::Exceptions::NoLTIDeployment.new(iss: iss, deployment_id: deployment_id) if deployment.nil?
80
+
81
+ cache_key = "#{deployment.cache_key_with_version}/services_authorization"
82
+ tries = 1
83
+
84
+ begin
85
+ authorization = Rails.cache.read(cache_key)
86
+ return authorization if authorization.present?
87
+
88
+ authorization = request_token_uncached(iss: iss, deployment_id: deployment_id)
89
+
90
+ # Subtract a few seconds so we don't use an expired token
91
+ expires_in = authorization["expires_in"].to_i - 10
92
+
93
+ Rails.cache.write(
94
+ cache_key,
95
+ authorization,
96
+ expires_in: expires_in,
97
+ )
98
+
99
+ rescue AtomicLti::Exceptions::RateLimitError => e
100
+ if tries < AUTHORIZATION_TRIES
101
+ Rails.logger.warn("LTI Request token error: Rate limit exception, sleeping")
102
+ sleep rand(1.0..2.0)
103
+ tries += 1
104
+ retry
105
+ else
106
+ raise e
107
+ end
108
+ end
109
+ authorization
110
+ end
111
+
112
+ def self.request_token_uncached(iss:, deployment_id:)
113
+ # Details here:
114
+ # https://www.imsglobal.org/spec/security/v1p0/#using-json-web-tokens-with-oauth-2-0-client-credentials-grant
115
+ body = {
116
+ grant_type: "client_credentials",
117
+ client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
118
+ scope: AtomicLti.scopes,
119
+ client_assertion: client_assertion(iss: iss, deployment_id: deployment_id),
120
+ }
121
+ headers = {
122
+ "Content-Type" => "application/x-www-form-urlencoded",
123
+ }
124
+
125
+ deployment = AtomicLti::Deployment.find_by(iss: iss, deployment_id: deployment_id)
126
+
127
+ raise AtomicLti::Exceptions::NoLTIDeployment.new(iss: iss, deployment_id: deployment_id) if deployment.nil?
128
+
129
+ platform = deployment.platform
130
+
131
+ raise AtomicLti::Exceptions::NoLTIPlatform.new(iss: iss, deployment_id: deployment_id) if platform.nil?
132
+
133
+ result = HTTParty.post(
134
+ platform.token_url,
135
+ body: body,
136
+ headers: headers
137
+ )
138
+
139
+ if !result.success?
140
+ Rails.logger.warn(result.body)
141
+
142
+ # Canvas rate limit error
143
+ raise AtomicLti::Exceptions::RateLimitError if /rate limit/i.match?(result.body)
144
+
145
+ raise AtomicLti::Exceptions::JwtIssueError.new(result.body)
146
+ end
147
+
148
+ JSON.parse(result.body)
149
+ end
150
+
151
+ end
152
+ end
@@ -0,0 +1,213 @@
1
+ # https://canvas.instructure.com/doc/api/tools_xml.html
2
+ # LTI gem docs: https://github.com/instructure/ims-lti
3
+
4
+ # These are the available LTI placements in Canvas.
5
+ # Placements that are implemented:
6
+ # account_navigation
7
+ # course_navigation
8
+ # editor_button
9
+ # global_navigation
10
+ # link_selection
11
+ # post_grades
12
+ # resource_selection
13
+ # assignment_selection
14
+ # user_navigation
15
+ # assignment_configuration
16
+ # assignment_edit
17
+ # assignment_view
18
+ # assignment_menu
19
+ # collaboration
20
+ # course_home_sub_navigation
21
+ # course_settings_sub_navigation
22
+ # discussion_topic_menu
23
+ # file_menu
24
+ # homework_submission
25
+ # migration_selection
26
+ # module_menu
27
+ # quiz_menu
28
+ # tool_configuration
29
+ # wiki_page_menu
30
+
31
+ module AtomicLti
32
+ class Config
33
+ # Converts old lti into lti advantage
34
+ # NOTE this is a work in progress and may not correctly convert all LTI configs.
35
+ def self.lti_to_lti_advantage(jwk, domain, args = {})
36
+ raise ::Exceptions::LtiConfigMissing, "Please provide an LTI launch url" if args[:launch_url].blank?
37
+ raise ::Exceptions::LtiConfigMissing, "Please provide an LTI secure launch url" if args[:secure_launch_url].blank?
38
+
39
+ if args[:content_migration].present?
40
+ raise Exceptions::LtiConfigMissing, "Please provide an IMS export url" if args[:export_url].blank?
41
+ raise Exceptions::LtiConfigMissing, "Please provide an IMS import url" if args[:import_url].blank?
42
+ end
43
+
44
+ {
45
+ title: args[:title],
46
+ scopes: AtomicLti::Definitions.scopes,
47
+ icon: icon(domain, args),
48
+ target_link_uri: args[:launch_url],
49
+ oidc_initiation_url: "#{args[:launch_url]}/init",
50
+ public_jwk: jwk.to_json,
51
+ description: args[:description],
52
+ custom_fields: custom_fields_from_args(domain, args[:title], args),
53
+ extensions: [
54
+ {
55
+ "platform": "canvas.instructure.com",
56
+ "domain": "https://#{domain}",
57
+ "tool_id": "helloworld",
58
+ "settings": {
59
+ "privacy_level": "public",
60
+ "text": args[:title],
61
+ "icon_url": "https://#{domain}/atomicjolt.png",
62
+ "selection_width": 500,
63
+ "selection_height": 500,
64
+ "placements": placements(domain, args[:title], args),
65
+ },
66
+ },
67
+ ],
68
+ }
69
+ end
70
+
71
+ def self.placements(domain, text, args)
72
+ conf = []
73
+ conf << placement_from_args(:resource_selection, domain, text, args)
74
+ conf << placement_from_args(:global_navigation, domain, text, args)
75
+ conf << placement_from_args(:user_navigation, domain, text, args)
76
+ conf << placement_from_args(:course_navigation, domain, text, args)
77
+ conf << placement_from_args(:account_navigation, domain, text, args)
78
+ conf << placement_from_args(:post_grades, domain, text, args)
79
+ conf << placement_from_args(:assignment_configuration, domain, text, args)
80
+ conf << placement_from_args(:assignment_edit, domain, text, args)
81
+ conf << placement_from_args(:assignment_menu, domain, text, args)
82
+ conf << placement_from_args(:collaboration, domain, text, args)
83
+ conf << placement_from_args(:course_home_sub_navigation, domain, text, args)
84
+ conf << placement_from_args(:course_settings_sub_navigation, domain, text, args)
85
+ conf << placement_from_args(:discussion_topic_menu, domain, text, args)
86
+ conf << placement_from_args(:file_menu, domain, text, args)
87
+ conf << placement_from_args(:homework_submission, domain, text, args)
88
+ conf << placement_from_args(:migration_selection, domain, text, args)
89
+ conf << placement_from_args(:quiz_menu, domain, text, args)
90
+ conf << placement_from_args(:tool_configuration, domain, text, args)
91
+ conf << editor_button_from_args(domain, text, args)
92
+ conf << assignment_selection_from_args(domain, text, args)
93
+ conf << link_selection_from_args(domain, text, args)
94
+ conf << assignment_view_from_args(domain, text, args)
95
+ conf << module_menu_from_args(domain, text, args)
96
+ conf << wiki_page_menu_from_args(domain, text, args)
97
+ conf << content_migration_args(domain, text, args)
98
+ conf.compact
99
+ end
100
+
101
+ def self.icon(domain, args)
102
+ if args[:icon].present?
103
+ args[:icon].include?("http") ? args[:icon] : "https://#{domain}/#{args[:icon]}"
104
+ end
105
+ end
106
+
107
+ def self.custom_fields_from_args(_domain, _text, args = {})
108
+ custom_fields = {
109
+ custom_canvas_api_domain: "$Canvas.api.domain",
110
+ }
111
+ if args[:custom_fields].present?
112
+ custom_fields.merge(args[:custom_fields]).stringify_keys
113
+ else
114
+ custom_fields.stringify_keys
115
+ end
116
+ end
117
+
118
+ def self.placement_from_args(placement, domain, text, args = {})
119
+ if args[placement].present?
120
+ default_configs_from_args(args, domain, text, placement).merge(
121
+ args[:placement].stringify_keys,
122
+ )
123
+ end
124
+ end
125
+
126
+ def self.editor_button_from_args(domain, _text, args = {})
127
+ if args[:editor_button].present?
128
+ config = args[:editor_button].stringify_keys
129
+ config["icon_url"] = "https://#{domain}/#{args[:editor_button][:icon_url]}"
130
+ config.delete("icon")
131
+ selection_config_from_args!(args, config, :editor_button)
132
+ end
133
+ end
134
+
135
+ def self.assignment_selection_from_args(_domain, _text, args = {})
136
+ if args[:assignment_selection].present?
137
+ config = args[:assignment_selection].stringify_keys
138
+ selection_config_from_args!(args, config, :assignment_selection)
139
+ end
140
+ end
141
+
142
+ def self.link_selection_from_args(_domain, _text, args = {})
143
+ if args[:link_selection].present?
144
+ config = args[:link_selection].stringify_keys
145
+ selection_config_from_args!(args, config, :link_selection)
146
+ end
147
+ end
148
+
149
+ def self.assignment_view_from_args(domain, text, args = {})
150
+ if args[:assignment_view].present?
151
+ config = default_configs_from_args(args, domain, text, :assignment_view)
152
+ config["visibility"] ||= "admins"
153
+ config
154
+ end
155
+ end
156
+
157
+ def self.module_menu_from_args(domain, text, args = {})
158
+ if args[:module_menu].present?
159
+ config["module_menu"] = args[:module_menu].stringify_keys
160
+ if config["module_menu"]["message_type"].present?
161
+ selection_config_from_args!(args, config, :module_menu)
162
+ end
163
+ default_configs_from_args(args, domain, text, :module_menu)
164
+ end
165
+ end
166
+
167
+ def self.wiki_page_menu_from_args(domain, text, args = {})
168
+ if args[:wiki_page_menu].present?
169
+ config = default_configs_from_args(args, domain, text, :wiki_page_menu).merge(
170
+ args[:wiki_page_menu].stringify_keys,
171
+ )
172
+ if args[:wiki_page_menu][:message_type].present?
173
+ selection_config_from_args!(args, config, :wiki_page_menu)
174
+ else
175
+ config
176
+ end
177
+ end
178
+ end
179
+
180
+ def self.content_migration_args(_domain, _text, args = {})
181
+ if args[:content_migration].present?
182
+ config = args[:content_migration] || {}
183
+ config["export_start_url"] ||= args[:export_url]
184
+ config["import_start_url"] ||= args[:import_url]
185
+ config
186
+ end
187
+ end
188
+
189
+ def self.selection_config_from_args!(args, config, placement)
190
+ config["placement"] = placement
191
+ config["message_type"] ||= "ContentItemSelectionRequest"
192
+ config["url"] ||= args[:launch_url]
193
+ config
194
+ end
195
+
196
+ def self.assignment_configs_from_args!(args, config, key)
197
+ config[key] = args[key].stringify_keys
198
+ config[key]["url"] ||= args[:launch_url]
199
+ # launch_height and launch_width are optional. Include them in the LTI config to set to a specific value
200
+ end
201
+
202
+ def self.default_configs_from_args(_args, _domain, text, placement)
203
+ {
204
+ "placement": placement,
205
+ "text": text,
206
+ "enabled": true,
207
+ "icon_url": "https://{domain}/atomicjolt.png",
208
+ "message_type": "LtiResourceLinkRequest",
209
+ "target_link_uri": "https://{domain}/lti_launches",
210
+ }
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,36 @@
1
+ module AtomicLti
2
+
3
+ module DeepLinking
4
+
5
+ # # ###########################################################
6
+ # # Create a jwt to sign a response to the platform
7
+ def self.create_deep_link_jwt(iss:, deployment_id:, content_items:, deep_link_claim_data: nil)
8
+ deployment = AtomicLti::Deployment.find_by(iss: iss, deployment_id: deployment_id)
9
+
10
+ raise AtomicLti::Exceptions::NoLTIDeployment(iss, deployment_id) if deployment.nil?
11
+
12
+ install = deployment.install
13
+ raise AtomicLti::Exceptions::NoLTIInstall(iss, deployment_id) if install.nil?
14
+
15
+ payload = {
16
+ iss: install.client_id, # A unique identifier for the entity that issued the JWT
17
+ aud: iss, # Authorization server identifier
18
+ iat: Time.now.to_i, # Timestamp for when the JWT was created
19
+ exp: Time.now.to_i + 300, # Timestamp for when the JWT should be treated as having expired
20
+ # (after allowing a margin for clock skew)
21
+ azp: install.client_id,
22
+ nonce: SecureRandom.hex(10),
23
+ AtomicLti::Definitions::MESSAGE_TYPE => "LtiDeepLinkingResponse",
24
+ AtomicLti::Definitions::LTI_VERSION => "1.3.0",
25
+ AtomicLti::Definitions::DEPLOYMENT_ID => deployment_id,
26
+ AtomicLti::Definitions::CONTENT_ITEM_CLAIM => content_items
27
+ }
28
+
29
+ if deep_link_claim_data.present?
30
+ payload[AtomicLti::Definitions::DEEP_LINKING_DATA_CLAIM] = deep_link_claim_data
31
+ end
32
+
33
+ AtomicLti::Authorization.sign_tool_jwt(payload)
34
+ end
35
+ end
36
+ end