atomic_lti 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|