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,169 @@
1
+ module AtomicLti
2
+ class Definitions
3
+ LTI_VERSION = "https://purl.imsglobal.org/spec/lti/claim/version".freeze
4
+ LAUNCH_PRESENTATION = "https://purl.imsglobal.org/spec/lti/claim/launch_presentation".freeze
5
+ DEPLOYMENT_ID = "https://purl.imsglobal.org/spec/lti/claim/deployment_id".freeze
6
+ MESSAGE_TYPE = "https://purl.imsglobal.org/spec/lti/claim/message_type".freeze
7
+
8
+ # Claims
9
+ CONTEXT_CLAIM = "https://purl.imsglobal.org/spec/lti/claim/context".freeze
10
+ RESOURCE_LINK_CLAIM = "https://purl.imsglobal.org/spec/lti/claim/resource_link".freeze
11
+ TOOL_PLATFORM_CLAIM = "https://purl.imsglobal.org/spec/lti/claim/tool_platform".freeze
12
+ AGS_CLAIM = "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint".freeze
13
+ BASIC_OUTCOME_CLAIM = "https://purl.imsglobal.org/spec/lti-bo/claim/basicoutcome".freeze
14
+
15
+ MENTOR_CLAIM = "https://purl.imsglobal.org/spec/lti/claim/role_scope_mentor".freeze
16
+ ROLES_CLAIM = "https://purl.imsglobal.org/spec/lti/claim/roles".freeze
17
+
18
+ CUSTOM_CLAIM = "https://purl.imsglobal.org/spec/lti/claim/custom".freeze
19
+ EXTENSION_CLAIM = "http://www.ExamplePlatformVendor.com/session".freeze
20
+
21
+ LIS_CLAIM = "https://purl.imsglobal.org/spec/lti/claim/lis".freeze
22
+ TARGET_LINK_URI_CLAIM = "https://purl.imsglobal.org/spec/lti/claim/target_link_uri".freeze
23
+ LTI11_LEGACY_USER_ID_CLAIM = "https://purl.imsglobal.org/spec/lti/claim/lti11_legacy_user_id".freeze
24
+ DEEP_LINKING_CLAIM = "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings".freeze
25
+ DEEP_LINKING_DATA_CLAIM = "https://purl.imsglobal.org/spec/lti-dl/claim/data".freeze
26
+ DEEP_LINKING_TOOL_MSG_CLAIM = "https://purl.imsglobal.org/spec/lti-dl/claim/msg".freeze
27
+ DEEP_LINKING_TOOL_LOG_CLAIM = "https://purl.imsglobal.org/spec/lti-dl/claim/log".freeze
28
+ CONTENT_ITEM_CLAIM = "https://purl.imsglobal.org/spec/lti-dl/claim/content_items".freeze
29
+ NAMES_AND_ROLES_CLAIM = "https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice".freeze
30
+
31
+ NAMES_AND_ROLES_SERVICE_VERSIONS = ["2.0"].freeze
32
+
33
+ CALIPER_CLAIM = "https://purl.imsglobal.org/spec/lti-ces/claim/caliper-endpoint-service".freeze
34
+
35
+ TOOL_LAUNCH_CALIPER_CONTEXT = "http://purl.imsglobal.org/ctx/caliper/v1p1/ToolLaunchProfile-extension".freeze
36
+ TOOL_USE_CALIPER_CONTEXT = "http://purl.imsglobal.org/ctx/caliper/v1p1".freeze
37
+
38
+ # Scopes
39
+ AGS_SCOPE_LINE_ITEM = "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem".freeze
40
+ AGS_SCOPE_LINE_ITEM_READONLY = "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly".freeze
41
+ AGS_SCOPE_RESULT = "https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly".freeze
42
+ AGS_SCOPE_SCORE = "https://purl.imsglobal.org/spec/lti-ags/scope/score".freeze
43
+ NAMES_AND_ROLES_SCOPE = "https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly".freeze
44
+ CALIPER_SCOPE = "https://purl.imsglobal.org/spec/lti-ces/v1p0/scope/send".freeze
45
+
46
+ STUDENT_SCOPE = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student".freeze
47
+ INSTRUCTOR_SCOPE = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor".freeze
48
+ LEARNER_SCOPE = "http://purl.imsglobal.org/vocab/lis/v2/membership#Learner".freeze
49
+ MENTOR_SCOPE = "http://purl.imsglobal.org/vocab/lis/v2/membership#Mentor".freeze
50
+ MENTOR_ROLE_SCOPE = "a62c52c02ba262003f5e".freeze
51
+
52
+ # Launch contexts
53
+ COURSE_CONTEXT = "http://purl.imsglobal.org/vocab/lis/v2/course#CourseOffering".freeze
54
+ ACCOUNT_CONTEXT = "Account".freeze
55
+
56
+ # Configuration
57
+ TOOL_CONFIGURATION = "https://purl.imsglobal.org/spec/lti-tool-configuration".freeze
58
+
59
+ # Specfies all available scopes.
60
+ def self.scopes
61
+ [
62
+ AGS_SCOPE_LINE_ITEM,
63
+ AGS_SCOPE_LINE_ITEM_READONLY,
64
+ AGS_SCOPE_RESULT,
65
+ AGS_SCOPE_SCORE,
66
+ NAMES_AND_ROLES_SCOPE,
67
+ ]
68
+ end
69
+
70
+ CANVAS_PUBLIC_LTI_KEYS_URL = "https://canvas.instructure.com/api/lti/security/jwks".freeze
71
+ CANVAS_OIDC_URL = "https://canvas.instructure.com/api/lti/authorize_redirect".freeze
72
+ CANVAS_AUTH_TOKEN_URL = "https://canvas.instructure.com/login/oauth2/token".freeze
73
+
74
+ CANVAS_BETA_PUBLIC_LTI_KEYS_URL = "https://canvas.beta.instructure.com/api/lti/security/jwks".freeze
75
+ CANVAS_BETA_AUTH_TOKEN_URL = "https://canvas.beta.instructure.com/login/oauth2/token".freeze
76
+ CANVAS_BETA_OIDC_URL = "https://canvas.beta.instructure.com/api/lti/authorize_redirect".freeze
77
+
78
+ CANVAS_SUBMISSION_TYPE = "https://canvas.instructure.com/lti/submission_type".freeze
79
+
80
+ # Roles
81
+ # Below are all the roles specified in the LTI 1.3 spec. (https://www.imsglobal.org/spec/lti/v1p3#role-vocabularies-0)
82
+ ## Core system roles
83
+ ADMINISTRATOR_SYSTEM_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/system/person#Administrator".freeze
84
+ NONE_SYSTEM_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/system/person#None".freeze
85
+ ## Non‑core system roles
86
+ ACCOUNT_ADMIN_SYSTEM_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/system/person#AccountAdmin".freeze
87
+ CREATOR_SYSTEM_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/system/person#Creator".freeze
88
+ SYS_ADMIN_SYSTEM_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/system/person#SysAdmin".freeze
89
+ SYS_SUPPORT_SYSTEM_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/system/person#SysSupport".freeze
90
+ USER_SYSTEM_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/system/person#User".freeze
91
+ ## Core institution roles
92
+ ADMINISTRATOR_INSTITUTION_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Administrator".freeze
93
+ FACULTY_INSTITUTION_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Faculty".freeze
94
+ GUEST_INSTITUTION_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Guest".freeze
95
+ NONE_INSTITUTION_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#None".freeze
96
+ OTHER_INSTITUTION_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Other".freeze
97
+ STAFF_INSTITUTION_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Staff".freeze
98
+ STUDENT_INSTITUTION_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student".freeze
99
+ ## Non‑core institution roles
100
+ ALUMNI_INSTITUTION_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Alumni".freeze
101
+ INSTRUCTOR_INSTITUTION_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor".freeze
102
+ LEARNER_INSTITUTION_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Learner".freeze
103
+ MEMBER_INSTITUTION_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Member".freeze
104
+ MENTOR_INSTITUTION_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Mentor".freeze
105
+ OBSERVER_INSTITUTION_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Observer".freeze
106
+ PROSPECTIVE_STUDENT_INSTITUTION_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#ProspectiveStudent".freeze
107
+ ## Core context roles
108
+ ADMINISTRATOR_CONTEXT_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/membership#Administrator".freeze
109
+ CONTENT_DEVELOPER_CONTEXT_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/membership#ContentDeveloper".freeze
110
+ INSTRUCTOR_CONTEXT_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor".freeze
111
+ LEARNER_CONTEXT_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/membership#Learner".freeze
112
+ MENTOR_CONTEXT_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/membership#Mentor".freeze
113
+ ## Non‑core context roles
114
+ MANAGER_CONTEXT_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/membership#Manager".freeze
115
+ MEMBER_CONTEXT_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/membership#Member".freeze
116
+ OFFICER_CONTEXT_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/membership#Officer".freeze
117
+
118
+ ADMINISTRATOR_ROLES = [
119
+ ADMINISTRATOR_SYSTEM_ROLE,
120
+ ACCOUNT_ADMIN_SYSTEM_ROLE,
121
+ ADMINISTRATOR_INSTITUTION_ROLE,
122
+ ADMINISTRATOR_CONTEXT_ROLE,
123
+ ].freeze
124
+
125
+ INSTRUCTOR_ROLES = [
126
+ INSTRUCTOR_INSTITUTION_ROLE,
127
+ INSTRUCTOR_CONTEXT_ROLE,
128
+ ].freeze
129
+
130
+ STUDENT_ROLES = [
131
+ STUDENT_INSTITUTION_ROLE,
132
+ LEARNER_CONTEXT_ROLE,
133
+ ].freeze
134
+
135
+ OBSERVER_ROLES = [
136
+ MENTOR_INSTITUTION_ROLE,
137
+ #NON_CREDIT_LEARNER,
138
+ ].freeze
139
+
140
+ def self.lms_host(payload)
141
+ host = if deep_link_launch?(payload)
142
+ payload.dig(AtomicLti::Definitions::DEEP_LINKING_CLAIM, "deep_link_return_url")
143
+ else
144
+ payload.dig(AtomicLti::Definitions::LAUNCH_PRESENTATION, "return_url")
145
+ end
146
+ UrlHelper.safe_host(host)
147
+ end
148
+
149
+ def self.lms_url(payload)
150
+ "https://#{lms_host(payload)}"
151
+ end
152
+
153
+ def self.deep_link_launch?(payload)
154
+ payload[AtomicLti::Definitions::MESSAGE_TYPE] == "LtiDeepLinkingRequest"
155
+ end
156
+
157
+ def self.names_and_roles_launch?(payload)
158
+ return false unless payload[AtomicLti::Definitions::NAMES_AND_ROLES_CLAIM]
159
+
160
+ payload[AtomicLti::Definitions::NAMES_AND_ROLES_CLAIM]["service_versions"] ==
161
+ AtomicLti::Definitions::NAMES_AND_ROLES_SERVICE_VERSIONS
162
+ end
163
+
164
+ def self.assignment_and_grades_launch?(payload)
165
+ payload[AtomicLti::Definitions::AGS_CLAIM]
166
+ end
167
+
168
+ end
169
+ end
@@ -0,0 +1,87 @@
1
+ module AtomicLti
2
+ module Exceptions
3
+
4
+ # General exceptions
5
+ class AtomicLtiException < StandardError
6
+ end
7
+
8
+ class LineItemError < AtomicLtiException
9
+ end
10
+
11
+ class ConfigurationError < AtomicLtiException
12
+ end
13
+
14
+ class NamesAndRolesError < AtomicLtiException
15
+ end
16
+
17
+ class ScoreError < AtomicLtiException
18
+ end
19
+
20
+ class StateError < AtomicLtiException
21
+ end
22
+
23
+ class OpenIDStateError < AtomicLtiException
24
+ end
25
+
26
+ class OpenIDRedirectError < AtomicLtiException
27
+ end
28
+
29
+ class JwtIssueError < AtomicLtiException
30
+ end
31
+
32
+ class LineItemMissing < LineItemError
33
+ end
34
+
35
+ class RateLimitError < AtomicLtiException
36
+ end
37
+
38
+ class InvalidLTIVersion < AtomicLtiException
39
+ def initialize(msg="Invalid LTI version")
40
+ super(msg)
41
+ end
42
+ end
43
+
44
+ class NoLTIVersion < AtomicLtiException
45
+ def initialize(msg="No LTI Version provided")
46
+ super(msg)
47
+ end
48
+ end
49
+
50
+ class NoLTIToken < AtomicLtiException
51
+ def initialize(msg="No LTI token provided")
52
+ super(msg)
53
+ end
54
+ end
55
+
56
+ class InvalidLTIToken < AtomicLtiException
57
+ def initialize(msg="Invalid LTI token provided")
58
+ super(msg)
59
+ end
60
+ end
61
+
62
+ # Not found exceptions
63
+ class AtomicLtiNotFoundException < StandardError
64
+ end
65
+
66
+ class NoLTIDeployment < AtomicLtiNotFoundException
67
+ def initialize(iss:, deployment_id:)
68
+ msg="No LTI Deployment found for iss: #{iss} and deployment_id #{deployment_id}"
69
+ super(msg)
70
+ end
71
+ end
72
+
73
+ class NoLTIInstall < AtomicLtiNotFoundException
74
+ def initialize(iss:, deployment_id:)
75
+ msg="No LTI Install found for iss: #{iss} and deployment_id #{deployment_id}"
76
+ super(msg)
77
+ end
78
+ end
79
+
80
+ class NoLTIPlatform < AtomicLtiNotFoundException
81
+ def initialize(iss:, deployment_id:)
82
+ msg="No LTI Platform associated with the LTI Install. iss: #{iss} and deployment_id #{deployment_id}"
83
+ super(msg)
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,94 @@
1
+ module AtomicLti
2
+ class Lti
3
+
4
+ def self.validate!(decoded_token, requested_target_link_uri = nil, validate_target_link_url = false)
5
+ if decoded_token.blank?
6
+ raise AtomicLti::Exceptions::InvalidLTIToken
7
+ end
8
+
9
+ errors = []
10
+
11
+ if decoded_token["iss"].blank?
12
+ errors.push("LTI token is missing required field iss")
13
+ end
14
+
15
+ if decoded_token["sub"].blank?
16
+ errors.push("LTI token is missing required field sub")
17
+ end
18
+
19
+ if decoded_token[AtomicLti::Definitions::DEPLOYMENT_ID].blank?
20
+ errors.push(
21
+ "LTI token is missing required field #{AtomicLti::Definitions::DEPLOYMENT_ID}"
22
+ )
23
+ end
24
+
25
+ if decoded_token[AtomicLti::Definitions::MESSAGE_TYPE].blank?
26
+ errors.push(
27
+ "LTI token is missing required claim #{AtomicLti::Definitions::MESSAGE_TYPE}"
28
+ )
29
+ end
30
+
31
+ if decoded_token[AtomicLti::Definitions::MESSAGE_TYPE] === "LtiResourceLinkRequest"
32
+ errors.concat(validate_resource_link_request(decoded_token, requested_target_link_uri, validate_target_link_url))
33
+ end
34
+
35
+ if decoded_token[AtomicLti::Definitions::ROLES_CLAIM].blank?
36
+ errors.push(
37
+ "LTI token is missing required claim #{AtomicLti::Definitions::ROLES_CLAIM}"
38
+ )
39
+ end
40
+
41
+ if errors.length > 0
42
+ raise AtomicLti::Exceptions::InvalidLTIToken.new(errors.join(" "))
43
+ end
44
+
45
+ if decoded_token[AtomicLti::Definitions::LTI_VERSION].blank?
46
+ raise AtomicLti::Exceptions::NoLTIVersion
47
+ end
48
+
49
+ raise AtomicLti::Exceptions::InvalidLTIVersion unless valid_version?(decoded_token)
50
+
51
+ true
52
+ end
53
+
54
+ def self.validate_resource_link_request(decoded_token, requested_target_link_uri = nil, validate_target_link_url = false)
55
+ errors = []
56
+
57
+ if decoded_token[AtomicLti::Definitions::TARGET_LINK_URI_CLAIM].blank?
58
+ errors.push(
59
+ "LTI token is missing required claim #{AtomicLti::Definitions::TARGET_LINK_URI_CLAIM}"
60
+ )
61
+ end
62
+
63
+ # Validate that we are at the target_link_uri
64
+ target_link_uri = decoded_token[AtomicLti::Definitions::TARGET_LINK_URI_CLAIM]
65
+ if validate_target_link_url && target_link_uri != requested_target_link_uri
66
+ errors.push(
67
+ "LTI token target link uri '#{target_link_uri}' doesn't match url '#{requested_target_link_uri}'"
68
+ )
69
+ end
70
+
71
+ if decoded_token[AtomicLti::Definitions::RESOURCE_LINK_CLAIM].blank?
72
+ errors.push(
73
+ "LTI token is missing required claim #{AtomicLti::Definitions::RESOURCE_LINK_CLAIM}"
74
+ )
75
+ end
76
+
77
+ if decoded_token.dig(AtomicLti::Definitions::RESOURCE_LINK_CLAIM, "id").blank?
78
+ errors.push(
79
+ "LTI token is missing required field id from the claim #{AtomicLti::Definitions::RESOURCE_LINK_CLAIM}"
80
+ )
81
+ end
82
+
83
+ errors
84
+ end
85
+
86
+ def self.valid_version?(decoded_token)
87
+ if decoded_token[AtomicLti::Definitions::LTI_VERSION]
88
+ decoded_token[AtomicLti::Definitions::LTI_VERSION].starts_with?("1.3")
89
+ else
90
+ false
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,22 @@
1
+ module AtomicLti
2
+ class OpenId
3
+ def self.validate_open_id_state(state)
4
+ state = AtomicLti::AuthToken.decode(state)[0]
5
+ if open_id_state = AtomicLti::OpenIdState.find_by(nonce: state["nonce"])
6
+ open_id_state.destroy
7
+ true
8
+ else
9
+ false
10
+ end
11
+ rescue StandardError => e
12
+ Rails.logger.info("Error decoding token: #{e} - #{e.backtrace}")
13
+ false
14
+ end
15
+
16
+ def self.state
17
+ nonce = SecureRandom.hex(64)
18
+ AtomicLti::OpenIdState.create!(nonce: nonce)
19
+ AtomicLti::AuthToken.issue_token({ nonce: nonce })
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,135 @@
1
+ module AtomicLti
2
+ # This is for extracting data from the lti jwt in more human-readable ways
3
+ class Params
4
+ attr_reader :token
5
+
6
+ def initialize(lti_token)
7
+ @token = lti_token.with_indifferent_access
8
+ end
9
+
10
+ def lti_advantage?
11
+ true
12
+ end
13
+
14
+ def deployment_id
15
+ token[AtomicLti::Definitions::DEPLOYMENT_ID]
16
+ end
17
+
18
+ def iss
19
+ token[:iss]
20
+ end
21
+
22
+ def version
23
+ token[AtomicLti::Definitions::LTI_VERSION]
24
+ end
25
+
26
+ def client_id
27
+ token[:aud]
28
+ end
29
+
30
+ def context_data
31
+ token[AtomicLti::Definitions::CONTEXT_CLAIM] || {}
32
+ end
33
+
34
+ def launch_context
35
+ # This is an array, I'm not sure what it means to have more than one
36
+ # value. In courses and accounts there's only one value
37
+ contexts = context_data[:type] || []
38
+ if contexts.include? AtomicLti::Definitions::COURSE_CONTEXT
39
+ "COURSE"
40
+ elsif contexts.include? AtomicLti::Definitions::ACCOUNT_CONTEXT
41
+ "ACCOUNT"
42
+ else
43
+ "UNKNOWN"
44
+ end
45
+ end
46
+
47
+ def context_id
48
+ context_data[:id]
49
+ end
50
+
51
+ def resource_link_data
52
+ token[AtomicLti::Definitions::RESOURCE_LINK_CLAIM] || {}
53
+ end
54
+
55
+ def resource_link_title
56
+ resource_link_data[:title]
57
+ end
58
+
59
+ def lis_data
60
+ token[AtomicLti::Definitions::LIS_CLAIM] || {}
61
+ end
62
+
63
+ def tool_platform_data
64
+ token[AtomicLti::Definitions::TOOL_PLATFORM_CLAIM] || {}
65
+ end
66
+
67
+ def product_family_code
68
+ tool_platform_data[:product_family_code]
69
+ end
70
+
71
+ def tool_consumer_instance_guid
72
+ tool_platform_data[:guid]
73
+ end
74
+
75
+ def tool_consumer_instance_name
76
+ tool_platform_data[:name]
77
+ end
78
+
79
+ def launch_presentation_data
80
+ token[AtomicLti::Definitions::LAUNCH_PRESENTATION] || {}
81
+ end
82
+
83
+ def launch_locale
84
+ launch_presentation_data[:locale]
85
+ end
86
+
87
+ def ags_data
88
+ token[AtomicLti::Definitions::AGS_CLAIM] || {}
89
+ end
90
+
91
+ def deep_linking_data
92
+ token[AtomicLti::Definitions::DEEP_LINKING_DATA_CLAIM] || {}
93
+ end
94
+
95
+ def deep_linking_claim
96
+ token[AtomicLti::Definitions::DEEP_LINKING_CLAIM]
97
+ end
98
+
99
+ def message_type
100
+ token[AtomicLti::Definitions::MESSAGE_TYPE]
101
+ end
102
+
103
+ def is_deep_link
104
+ AtomicLti::Definitions.deep_link_launch?(token)
105
+ end
106
+
107
+ # This extracts the custom parameters from the jwt token from the lti launch
108
+ # These values must be added to the developer key under "Custom Fields"
109
+ # for example: canvas_course_id=$Canvas.course.id
110
+ def custom_data
111
+ token[AtomicLti::Definitions::CUSTOM_CLAIM]&.reject { |_, s| s.to_s.start_with?("$Canvas") } || {}
112
+ end
113
+
114
+ def canvas_course_id
115
+ custom_data[:canvas_course_id]
116
+ end
117
+
118
+ def canvas_section_ids
119
+ custom_data[:canvas_section_ids]
120
+ end
121
+
122
+ def canvas_account_id
123
+ custom_data[:canvas_account_id]
124
+ end
125
+
126
+ def canvas_course_name
127
+ custom_data[:canvas_course_name]
128
+ end
129
+
130
+ def canvas_assignment_id
131
+ custom_data[:canvas_assignment_id]
132
+ end
133
+
134
+ end
135
+ end
@@ -0,0 +1,38 @@
1
+ module AtomicLti
2
+ module Services
3
+ class Base
4
+
5
+ def initialize(lti_token: nil, iss: nil, deployment_id: nil)
6
+
7
+ token_iss = nil
8
+ token_deployment_id = nil
9
+
10
+ if lti_token.present?
11
+ token_iss = lti_token.dig('iss')
12
+ token_deployment_id = lti_token.dig(AtomicLti::Definitions::DEPLOYMENT_ID)
13
+ end
14
+
15
+ @lti_token = lti_token
16
+ @iss = iss || token_iss
17
+ @deployment_id = deployment_id || token_deployment_id
18
+ end
19
+
20
+ def headers(options = {})
21
+ @token ||= AtomicLti::Authorization.request_token(iss: @iss, deployment_id: @deployment_id)
22
+ {
23
+ "Authorization" => "Bearer #{@token['access_token']}",
24
+ }.merge(options)
25
+ end
26
+
27
+ def get_next_url(response)
28
+ link = response.headers["link"]
29
+ return nil if link.blank?
30
+
31
+ if url = link.split(",").detect { |l| l.split(";")[1].strip == 'rel="next"' }
32
+ url.split(";")[0].gsub(/[\<\>\s]/, "")
33
+ end
34
+ end
35
+
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,90 @@
1
+ module AtomicLti
2
+ module Services
3
+ # Canvas API docs https://canvas.instructure.com/doc/api/line_items.html
4
+ class LineItems < AtomicLti::Services::Base
5
+
6
+ def endpoint(lti_token)
7
+ url = lti_token.dig(AtomicLti::Definitions::AGS_CLAIM, "lineitems")
8
+ raise AtomicLti::Exceptions::LineItemError, "Unable to access line items" unless url.present?
9
+ url
10
+ end
11
+
12
+ # Helper method to generate a default set of attributes
13
+ def self.generate(
14
+ label:,
15
+ max_score:,
16
+ start_date_time: nil,
17
+ end_date_time: nil,
18
+ resource_id: nil,
19
+ tag: nil,
20
+ resource_link_id: nil,
21
+ external_tool_url: nil
22
+ )
23
+ attrs = {
24
+ scoreMaximum: max_score,
25
+ label: label,
26
+ resourceId: resource_id,
27
+ tag: tag,
28
+ startDateTime: start_date_time,
29
+ endDateTime: end_date_time,
30
+ }
31
+ attrs["resourceLinkId"] = resource_link_id if resource_link_id
32
+ if external_tool_url
33
+ attrs[AtomicLti::Definitions::CANVAS_SUBMISSION_TYPE] = {
34
+ type: "external_tool",
35
+ external_tool_url: external_tool_url,
36
+ }
37
+ end
38
+ attrs
39
+ end
40
+
41
+ def generate(attrs)
42
+ self.class.generate(**attrs)
43
+ end
44
+
45
+ def self.can_manage_line_items?(lti_token)
46
+ lti_token.dig(AtomicLti::Definitions::AGS_CLAIM, "scope")&.
47
+ include?(AtomicLti::Definitions::AGS_SCOPE_LINE_ITEM)
48
+ end
49
+
50
+ def self.can_query_line_items?(lti_token)
51
+ can_manage_line_items?(lti_token) ||
52
+ lti_token.dig(AtomicLti::Definitions::AGS_CLAIM, "scope").
53
+ include?(AtomicLti::Definitions::AGS_SCOPE_LINE_ITEM_READONLY)
54
+ end
55
+
56
+ # List line items
57
+ # Canvas: https://canvas.beta.instructure.com/doc/api/line_items.html#method.lti/ims/line_items.index
58
+ def list(query = {})
59
+ accept = { "Accept" => "application/vnd.ims.lis.v2.lineitemcontainer+json" }
60
+ HTTParty.get(endpoint(@lti_token), headers: headers(accept), query: query)
61
+ end
62
+
63
+ # Get a specific line item
64
+ # https://canvas.beta.instructure.com/doc/api/line_items.html#method.lti/ims/line_items.show
65
+ def show(line_item_url)
66
+ accept = { "Accept" => "application/vnd.ims.lis.v2.lineitem+json" }
67
+ HTTParty.get(line_item_url, headers: headers(accept))
68
+ end
69
+
70
+ # Create a line item
71
+ # https://www.imsglobal.org/spec/lti-ags/v2p0/#creating-a-new-line-item
72
+ # Canvas: https://canvas.beta.instructure.com/doc/api/line_items.html#method.lti/ims/line_items.create
73
+ def create(attrs = nil)
74
+ content_type = { "Content-Type" => "application/vnd.ims.lis.v2.lineitem+json" }
75
+ HTTParty.post(endpoint(@lti_token), body: JSON.dump(attrs), headers: headers(content_type))
76
+ end
77
+
78
+ # Update a line item
79
+ # Canvas: https://canvas.beta.instructure.com/doc/api/line_items.html#method.lti/ims/line_items.update
80
+ def update(line_item_url, attrs)
81
+ content_type = { "Content-Type" => "application/vnd.ims.lis.v2.lineitem+json" }
82
+ HTTParty.put(line_item_url, body: JSON.dump(attrs), headers: headers(content_type))
83
+ end
84
+
85
+ def delete(line_item_url)
86
+ HTTParty.delete(line_item_url, headers: headers)
87
+ end
88
+ end
89
+ end
90
+ end