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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +22 -0
- data/Rakefile +13 -0
- data/app/assets/config/atomic_lti_manifest.js +1 -0
- data/app/assets/stylesheets/atomic_lti/application.css +0 -0
- data/app/assets/stylesheets/atomic_lti/jwks.css +4 -0
- data/app/controllers/atomic_lti/jwks_controller.rb +17 -0
- data/app/helpers/atomic_lti/launch_helper.rb +4 -0
- data/app/jobs/atomic_lti/application_job.rb +4 -0
- data/app/lib/atomic_lti/auth_token.rb +35 -0
- data/app/lib/atomic_lti/authorization.rb +152 -0
- data/app/lib/atomic_lti/config.rb +213 -0
- data/app/lib/atomic_lti/deep_linking.rb +36 -0
- data/app/lib/atomic_lti/definitions.rb +169 -0
- data/app/lib/atomic_lti/exceptions.rb +87 -0
- data/app/lib/atomic_lti/lti.rb +94 -0
- data/app/lib/atomic_lti/open_id.rb +22 -0
- data/app/lib/atomic_lti/params.rb +135 -0
- data/app/lib/atomic_lti/services/base.rb +38 -0
- data/app/lib/atomic_lti/services/line_items.rb +90 -0
- data/app/lib/atomic_lti/services/names_and_roles.rb +74 -0
- data/app/lib/atomic_lti/services/results.rb +18 -0
- data/app/lib/atomic_lti/services/score.rb +69 -0
- data/app/lib/atomic_lti/services/score_canvas.rb +47 -0
- data/app/mailers/atomic_lti/application_mailer.rb +6 -0
- data/app/models/atomic_lti/application_record.rb +5 -0
- data/app/models/atomic_lti/context.rb +10 -0
- data/app/models/atomic_lti/deployment.rb +13 -0
- data/app/models/atomic_lti/install.rb +11 -0
- data/app/models/atomic_lti/jwk.rb +41 -0
- data/app/models/atomic_lti/oauth_state.rb +5 -0
- data/app/models/atomic_lti/open_id_state.rb +5 -0
- data/app/models/atomic_lti/platform.rb +5 -0
- data/app/models/atomic_lti/platform_instance.rb +8 -0
- data/app/views/atomic_lti/launches/index.html.erb +11 -0
- data/app/views/atomic_lti/shared/redirect.html.erb +15 -0
- data/app/views/layouts/atomic_lti/application.html.erb +14 -0
- data/config/routes.rb +3 -0
- data/db/migrate/20220428175127_create_atomic_lti_platforms.rb +12 -0
- data/db/migrate/20220428175128_create_atomic_lti_platform_instances.rb +15 -0
- data/db/migrate/20220428175247_create_atomic_lti_installs.rb +11 -0
- data/db/migrate/20220428175305_create_atomic_lti_deployments.rb +13 -0
- data/db/migrate/20220428175336_create_atomic_lti_contexts.rb +15 -0
- data/db/migrate/20220428175423_create_atomic_lti_oauth_states.rb +10 -0
- data/db/migrate/20220503003528_create_atomic_lti_jwks.rb +12 -0
- data/db/migrate/20221010140920_create_open_id_state.rb +9 -0
- data/db/seeds.rb +29 -0
- data/lib/atomic_lti/engine.rb +9 -0
- data/lib/atomic_lti/error_handling_middleware.rb +33 -0
- data/lib/atomic_lti/open_id_middleware.rb +270 -0
- data/lib/atomic_lti/version.rb +3 -0
- data/lib/atomic_lti.rb +27 -0
- data/lib/tasks/atomic_lti_tasks.rake +4 -0
- 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
|