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
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