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