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,74 @@
1
+ module AtomicLti
2
+ module Services
3
+ class NamesAndRoles < AtomicLti::Services::Base
4
+
5
+ def initialize(lti_token:)
6
+ super(lti_token: lti_token)
7
+ end
8
+
9
+ def endpoint
10
+ url = @lti_token.dig(AtomicLti::Definitions::NAMES_AND_ROLES_CLAIM, "context_memberships_url")
11
+ raise AtomicLti::Exceptions::NamesAndRolesError, "Unable to access names and roles" unless url.present?
12
+
13
+ url
14
+ end
15
+
16
+ def url_for(query = nil)
17
+ url = endpoint.dup
18
+ url << "?#{query}" if query.present?
19
+ url
20
+ end
21
+
22
+ def self.enabled?(lti_token)
23
+ return false unless lti_token&.dig(AtomicLti::Definitions::NAMES_AND_ROLES_CLAIM)
24
+
25
+ (AtomicLti::Definitions::NAMES_AND_ROLES_SERVICE_VERSIONS &
26
+ (lti_token.dig(AtomicLti::Definitions::NAMES_AND_ROLES_CLAIM, "service_versions") || [])).present?
27
+ end
28
+
29
+ def valid?
30
+ self.class.enabled?(@lti_token)
31
+ end
32
+
33
+ # List names and roles
34
+ # limit query param - see 'Limit query parameter' section of NRPS spec
35
+ # to get differences - see 'Membership differences' section of NRPS spec
36
+ # query parameter of '{"role" => "Learner"}'
37
+ # will filter the memberships to just those which have a Learner role.
38
+ # query parameter of '{"rlid" => "49566-rkk96"}' will filter the memberships to just those which
39
+ # have access to the resource link with ID '49566-rkk96'
40
+ def list(query: {}, page_url: nil)
41
+ url = if page_url.present?
42
+ page_url
43
+ else
44
+ uri = Addressable::URI.parse(endpoint)
45
+ uri.query_values = (uri.query_values || {}).merge(query)
46
+ uri
47
+ end
48
+ verify_received_user_names(
49
+ HTTParty.get(
50
+ url,
51
+ headers: headers(
52
+ {
53
+ "Accept" => "application/vnd.ims.lti-nrps.v2.membershipcontainer+json",
54
+ },
55
+ ),
56
+ ),
57
+ )
58
+ end
59
+
60
+ def verify_received_user_names(names_and_roles_memberships)
61
+ if names_and_roles_memberships&.body.present?
62
+ members = JSON.parse(names_and_roles_memberships.body)["members"]
63
+ if members.present? && members.all? { |member| member["name"].nil? }
64
+ raise(
65
+ AtomicLti::Exceptions::NamesAndRolesError,
66
+ "Unable to fetch user data. Your LTI key may be set to private.",
67
+ )
68
+ end
69
+ end
70
+ names_and_roles_memberships
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,18 @@
1
+ module AtomicLti
2
+ module Services
3
+ # Canvas API docs: https://canvas.instructure.com/doc/api/result.html
4
+ class Results < AtomicLti::Services::Base
5
+
6
+ def list(line_item_id)
7
+ url = "#{line_item_id}/results"
8
+ HTTParty.get(url, headers: headers)
9
+ end
10
+
11
+ def show(line_item_id, result_id)
12
+ url = "#{line_item_id}/results/#{result_id}"
13
+ HTTParty.get(url, headers: headers)
14
+ end
15
+
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,69 @@
1
+ module AtomicLti
2
+ module Services
3
+ # Canvas docs: https://canvas.instructure.com/doc/api/score.html
4
+ class Score < AtomicLti::Services::Base
5
+
6
+ attr_accessor :id
7
+
8
+ def initialize(lti_token: nil, iss:nil, deployment_id: nil, id: nil)
9
+ super(lti_token: lti_token, iss: iss, deployment_id: deployment_id)
10
+ @id = id
11
+ end
12
+
13
+ def endpoint
14
+ if id.blank?
15
+ raise ::AtomicLti::Exceptions::ScoreError,
16
+ "Invalid id or no id provided. Unable to access scores. id should be in the form of a url."
17
+ end
18
+ uri = URI(id)
19
+ uri.path = uri.path+'/scores'
20
+ uri
21
+ end
22
+
23
+ def generate(
24
+ user_id:,
25
+ score:,
26
+ max_score:,
27
+ comment: nil,
28
+ timestamp: Time.now,
29
+ activity_progress: "Completed",
30
+ grading_progress: "FullyGraded"
31
+ )
32
+ {
33
+ # The lti_user_id or the Canvas user_id
34
+ userId: user_id,
35
+ # The Current score received in the tool for this line item and user, scaled to
36
+ # the scoreMaximum
37
+ scoreGiven: score,
38
+ # Maximum possible score for this result; it must be present if scoreGiven is
39
+ # present.
40
+ scoreMaximum: max_score,
41
+ # Comment visible to the student about this score.
42
+ comment: comment,
43
+ # Date and time when the score was modified in the tool. Should use subsecond
44
+ # precision.
45
+ timestamp: timestamp.iso8601(3),
46
+ # Indicate to Canvas the status of the user towards the activity's completion.
47
+ # Must be one of Initialized, Started, InProgress, Submitted, Completed
48
+ activityProgress: activity_progress,
49
+ # Indicate to Canvas the status of the grading process. A value of
50
+ # PendingManual will require intervention by a grader. Values of NotReady,
51
+ # Failed, and Pending will cause the scoreGiven to be ignored. FullyGraded
52
+ # values will require no action. Possible values are NotReady, Failed, Pending,
53
+ # PendingManual, FullyGraded
54
+ gradingProgress: grading_progress,
55
+ }
56
+ end
57
+
58
+ def send(attrs)
59
+ content_type = { "Content-Type" => "application/vnd.ims.lis.v1.score+json" }
60
+ HTTParty.post(
61
+ endpoint,
62
+ body: attrs.to_json,
63
+ headers: headers(content_type),
64
+ )
65
+ end
66
+
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,47 @@
1
+ module AtomicLti
2
+ module Services
3
+ # Canvas docs: https://canvas.instructure.com/doc/api/score.html
4
+ class ScoreCanvas < Score
5
+
6
+ def generate(
7
+ new_submission: true,
8
+ submission_type: nil,
9
+ submission_data: nil,
10
+ submitted_at: nil,
11
+ content_items: nil,
12
+ **standard_attrs
13
+ )
14
+ submission_data = {
15
+ # (EXTENSION field) flag to indicate that this is a new submission.
16
+ # Defaults to true unless submission_type is none.
17
+ new_submission: new_submission,
18
+
19
+ # (EXTENSION field) permissible values are: none, basic_lti_launch,
20
+ # online_text_entry, external_tool, online_upload, or online_url.
21
+ # Defaults to external_tool. Ignored if content_items are provided.
22
+ submission_type: submission_type,
23
+
24
+ # (EXTENSION field) submission data (URL or body text). Only used
25
+ # for submission_types basic_lti_launch, online_text_entry, online_url.
26
+ # Ignored if content_items are provided.
27
+ submission_data: submission_data,
28
+
29
+ # (EXTENSION field) Date and time that the submission was originally created.
30
+ # Should use ISO8601-formatted date with subsecond precision. This should match
31
+ # the data and time that the original submission happened in Canvas.
32
+ submitted_at: submitted_at,
33
+
34
+ # (EXTENSION field) Files that should be included with the submission. Each item
35
+ # should contain `type: file`, and a url pointing to the file. It can also contain
36
+ # a title, and an explicit MIME type if needed (otherwise, MIME type will be
37
+ # inferred from the title or url). If any items are present, submission_type
38
+ # will be online_upload.
39
+ content_items: content_items,
40
+ }.compact
41
+
42
+ super(**standard_attrs).
43
+ merge({ "https://canvas.instructure.com/lti/submission": submission_data })
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,6 @@
1
+ module AtomicLti
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: 'from@example.com'
4
+ layout 'mailer'
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module AtomicLti
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,10 @@
1
+ module AtomicLti
2
+ class Context < ApplicationRecord
3
+ belongs_to :platform, primary_key: :iss, foreign_key: :iss
4
+ # belongs_to :deployment, primary_key: [:iss, :deployment_id], foreign_key: [:iss, :deployment_id] # TODO: this breaks rspec
5
+
6
+ validates :context_id, presence: true
7
+ validates :deployment_id, presence: true
8
+ validates :iss, presence: true
9
+ end
10
+ end
@@ -0,0 +1,13 @@
1
+ module AtomicLti
2
+ class Deployment < ApplicationRecord
3
+ belongs_to :platform, primary_key: :iss, foreign_key: :iss
4
+ belongs_to :install, primary_key: [:iss, :client_id], foreign_key: [:iss, :client_id], optional: true
5
+
6
+ # we won't have platform_guid during dynamic registration
7
+ # belongs_to :platform_instance, primary_key: :guid, foreign_key: :platform_guid, optional: true
8
+
9
+ validates :deployment_id, presence: true
10
+ validates :iss, presence: true
11
+ # validates :platform_guid#, presence: true
12
+ end
13
+ end
@@ -0,0 +1,11 @@
1
+ module AtomicLti
2
+ class Install < ApplicationRecord
3
+ belongs_to :platform, primary_key: :iss, foreign_key: :iss
4
+
5
+ validates :client_id, presence: true
6
+ validates :iss, presence: true
7
+ def deployments
8
+ AtomicLti::Deployment.where("iss = ? AND client_id = ?", iss, client_id)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,41 @@
1
+ module AtomicLti
2
+ class Jwk < ApplicationRecord
3
+ before_create :generate_keys
4
+
5
+ def generate_keys
6
+ pkey = OpenSSL::PKey::RSA.generate(2048)
7
+ self.pem = pkey.to_pem
8
+ self.kid = pkey.to_jwk.thumbprint
9
+ end
10
+
11
+ def alg
12
+ "RS256"
13
+ end
14
+
15
+ def private_key
16
+ OpenSSL::PKey::RSA.new(pem)
17
+ end
18
+
19
+ def public_key
20
+ pkey = OpenSSL::PKey::RSA.new(pem)
21
+ pkey.public_key
22
+ end
23
+
24
+ def to_json
25
+ pkey = OpenSSL::PKey::RSA.new(pem)
26
+ json = JSON::JWK.new(pkey.public_key, kid: kid).as_json
27
+ json["use"] = "sig"
28
+ json["alg"] = alg
29
+ json
30
+ end
31
+
32
+ def to_pem
33
+ pkey = OpenSSL::PKey::RSA.new(pem)
34
+ pkey.public_key.to_pem
35
+ end
36
+
37
+ def self.current_jwk
38
+ self.last
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,5 @@
1
+ module AtomicLti
2
+ class OauthState < ApplicationRecord
3
+ validates :state, presence: true, uniqueness: true
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module AtomicLti
2
+ class OpenIdState < ApplicationRecord
3
+ validates :nonce, presence: true, uniqueness: true
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module AtomicLti
2
+ class Platform < ApplicationRecord
3
+ validates :iss, presence: true
4
+ end
5
+ end
@@ -0,0 +1,8 @@
1
+ module AtomicLti
2
+ class PlatformInstance < ApplicationRecord
3
+ belongs_to :platform, primary_key: :iss, foreign_key: :iss
4
+
5
+ validates :guid, presence: true
6
+ validates :iss, presence: true
7
+ end
8
+ end
@@ -0,0 +1,11 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ </head>
5
+ <body>
6
+ <h1> Atomic LTI launch </h1>
7
+ <pre>
8
+ <%= lti.token.pretty_inspect %>
9
+ </pre>
10
+ </body>
11
+ </html>
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <script type="text/javascript">
5
+ window.onload=function(){document.forms[0].submit();};
6
+ </script>
7
+ </head>
8
+ <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>
14
+ </body>
15
+ </html>
@@ -0,0 +1,14 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Atomic lti</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ </head>
9
+ <body>
10
+
11
+ <%= yield %>
12
+
13
+ </body>
14
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,3 @@
1
+ AtomicLti::Engine.routes.draw do
2
+ resources :jwks
3
+ end
@@ -0,0 +1,12 @@
1
+ class CreateAtomicLtiPlatforms < ActiveRecord::Migration[6.1]
2
+ def change
3
+ create_table :atomic_lti_platforms do |t|
4
+ t.string :iss, null: false
5
+ t.string :jwks_url, null: false
6
+ t.string :token_url, null: false
7
+ t.string :oidc_url, null: false
8
+ t.timestamps
9
+ end
10
+ add_index :atomic_lti_platforms, :iss, unique: true
11
+ end
12
+ end
@@ -0,0 +1,15 @@
1
+ class CreateAtomicLtiPlatformInstances < ActiveRecord::Migration[6.1]
2
+ def change
3
+ create_table :atomic_lti_platform_instances do |t|
4
+ t.string :iss, null: false
5
+ t.string :guid, null: false
6
+ t.string :name
7
+ t.string :version
8
+ t.string :product_family_code
9
+
10
+ t.timestamps
11
+ end
12
+ add_index :atomic_lti_platform_instances, [:guid, :iss],
13
+ unique: true, name: "index_atomic_lti_platform_instances_guid_iss"
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ class CreateAtomicLtiInstalls < ActiveRecord::Migration[6.1]
2
+ def change
3
+ create_table :atomic_lti_installs do |t|
4
+ t.string :iss, null: false
5
+ t.string :client_id, null: false
6
+ t.timestamps
7
+ end
8
+ add_index :atomic_lti_installs, [:client_id, :iss],
9
+ unique: true, name: "index_atomic_lti_installs_c_id_guid"
10
+ end
11
+ end
@@ -0,0 +1,13 @@
1
+ class CreateAtomicLtiDeployments < ActiveRecord::Migration[6.1]
2
+ def change
3
+ create_table :atomic_lti_deployments do |t|
4
+ t.string :deployment_id, null: false
5
+ t.string :client_id, null: false
6
+ t.string :platform_guid
7
+ t.string :iss, null: false
8
+ t.timestamps
9
+ end
10
+ add_index :atomic_lti_deployments, [:deployment_id, :iss],
11
+ unique: true, name: "index_atomic_lti_deployments_d_id_iss"
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ class CreateAtomicLtiContexts < ActiveRecord::Migration[6.1]
2
+ def change
3
+ create_table :atomic_lti_contexts do |t|
4
+ t.string :context_id, null: false
5
+ t.string :deployment_id, null: false
6
+ t.string :iss, null: false
7
+ t.string :label
8
+ t.string :title
9
+ t.string :types, array: true
10
+ t.timestamps
11
+ end
12
+ add_index :atomic_lti_contexts, [:context_id, :deployment_id, :iss],
13
+ unique: true, name: "index_atomic_lti_contexts_c_id_d_id_iss"
14
+ end
15
+ end
@@ -0,0 +1,10 @@
1
+ class CreateAtomicLtiOauthStates < ActiveRecord::Migration[6.1]
2
+ def change
3
+ create_table :atomic_lti_oauth_states do |t|
4
+ t.string :state, null: false
5
+ t.text :payload, null: false
6
+ t.timestamps
7
+ end
8
+ add_index :atomic_lti_oauth_states, :state
9
+ end
10
+ end
@@ -0,0 +1,12 @@
1
+ class CreateAtomicLtiJwks < ActiveRecord::Migration[6.1]
2
+ def change
3
+ create_table :atomic_lti_jwks do |t|
4
+ t.string :kid
5
+ t.string :pem
6
+ t.string :domain
7
+ t.timestamps
8
+ end
9
+ add_index :atomic_lti_jwks, :kid
10
+ add_index :atomic_lti_jwks, :domain
11
+ end
12
+ end
@@ -0,0 +1,9 @@
1
+ class CreateOpenIdState < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :atomic_lti_open_id_states do |t|
4
+ t.string :nonce
5
+ t.timestamps
6
+ end
7
+ add_index :atomic_lti_open_id_states, :nonce, unique: true
8
+ end
9
+ end
data/db/seeds.rb ADDED
@@ -0,0 +1,29 @@
1
+ # Add default jwk
2
+ AtomicLti::Jwk.find_or_create_by(domain: nil)
3
+
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"
9
+ ).find_or_create_by(iss: "https://canvas.instructure.com")
10
+
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",
15
+ ).find_or_create_by(iss: "https://canvas-beta.instructure.com")
16
+
17
+
18
+ AtomicLti::Install.create_with(iss: "https://canvas.instructure.com").find_or_create_by(client_id: "43460000000000525")
19
+
20
+ AtomicTenant::PinnedPlatformGuid.create(iss: "https://canvas.instructure.com", platform_guid: "4MRcxnx6vQbFXxhLb8005m5WXFM2Z2i8lQwhJ1QT:canvas-lms", application_id: 6, application_instance_id: 5)
21
+
22
+
23
+ # => #<AtomicTenant::LtiDeployment:0x00000001294e6018
24
+ # id: 1,
25
+ # iss: "https://canvas.instructure.com",
26
+ # deployment_id: "21089:1f5e1ee417cb2b17f86a1232122452ab3f6188f7",
27
+ # application_instance_id: 5,
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>
@@ -0,0 +1,9 @@
1
+ module AtomicLti
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace AtomicLti
4
+
5
+ config.generators do |g|
6
+ g.test_framework :rspec
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,33 @@
1
+ module AtomicLti
2
+ class ErrorHandlingMiddleware
3
+ def initialize(app)
4
+ @app = app
5
+ end
6
+
7
+ def render_error(env, status, message)
8
+ format = "text/plain"
9
+ body = message
10
+
11
+ render(status, body, format)
12
+ end
13
+
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]]
21
+ end
22
+
23
+ def call(env)
24
+ @app.call(env)
25
+
26
+ rescue AtomicLti::Exceptions::AtomicLtiNotFoundException => e
27
+ render_error(env, 404, e.message)
28
+
29
+ rescue AtomicLti::Exceptions::AtomicLtiException => e
30
+ render_error(env, 500, e.message)
31
+ end
32
+ end
33
+ end