atomic_lti 1.3.1 → 1.5.1
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 +4 -4
- data/README.md +13 -1
- data/app/assets/builds/atomic_lti/init_app.js +46 -0
- data/app/assets/builds/atomic_lti/init_app.js.map +7 -0
- data/app/assets/stylesheets/atomic_lti/launch.css +95 -0
- data/app/javascript/atomic_lti/init_app.js +3 -0
- data/app/lib/atomic_lti/authorization.rb +16 -9
- data/app/lib/atomic_lti/definitions.rb +38 -6
- data/app/lib/atomic_lti/exceptions.rb +15 -11
- data/app/lib/atomic_lti/lti.rb +13 -5
- data/app/lib/atomic_lti/open_id.rb +25 -13
- data/app/lib/atomic_lti/params.rb +2 -2
- data/app/lib/atomic_lti/role_enforcement_mode.rb +8 -0
- data/app/lib/atomic_lti/services/base.rb +8 -7
- data/app/lib/atomic_lti/services/line_items.rb +15 -10
- data/app/lib/atomic_lti/services/names_and_roles.rb +11 -7
- data/app/lib/atomic_lti/services/results.rb +4 -0
- data/app/lib/atomic_lti/services/score.rb +8 -4
- data/app/models/atomic_lti/jwk.rb +10 -1
- data/app/models/atomic_lti/open_id_state.rb +1 -0
- data/app/views/atomic_lti/shared/error.html.erb +17 -0
- data/app/views/atomic_lti/shared/init.html.erb +26 -0
- data/app/views/atomic_lti/shared/redirect.html.erb +21 -8
- data/db/migrate/20230726040941_add_state_to_open_id_state.rb +6 -0
- data/db/seeds.rb +8 -8
- data/lib/atomic_lti/error_handling_middleware.rb +19 -11
- data/lib/atomic_lti/open_id_middleware.rb +141 -62
- data/lib/atomic_lti/version.rb +1 -1
- data/lib/atomic_lti.rb +28 -1
- metadata +11 -3
@@ -0,0 +1,95 @@
|
|
1
|
+
.aj-centered-message {
|
2
|
+
max-width: 600px;
|
3
|
+
margin: 48px auto 0;
|
4
|
+
border: 1px solid #ccc;
|
5
|
+
padding: 24px 32px;
|
6
|
+
border-radius: 10px;
|
7
|
+
}
|
8
|
+
|
9
|
+
.aj-icon {
|
10
|
+
width: 24px;
|
11
|
+
color: #333;
|
12
|
+
}
|
13
|
+
|
14
|
+
.aj-title {
|
15
|
+
font-family: 'Lato', 'Helvetica Nue', Helvetica, Arial sans-serif;
|
16
|
+
font-weight: 400;
|
17
|
+
font-size: 20px;
|
18
|
+
line-height: 1;
|
19
|
+
gap: 12px;
|
20
|
+
color: #333;
|
21
|
+
display: flex;
|
22
|
+
align-items: center;
|
23
|
+
-webkit-font-smoothing: antialiased;
|
24
|
+
-moz-osx-font-smoothing: grayscale;
|
25
|
+
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.004);
|
26
|
+
}
|
27
|
+
|
28
|
+
.u-flex {
|
29
|
+
display: flex;
|
30
|
+
gap: 12px;
|
31
|
+
margin-top: 12px;
|
32
|
+
}
|
33
|
+
|
34
|
+
.u-flex > * {
|
35
|
+
margin: 0;
|
36
|
+
}
|
37
|
+
|
38
|
+
.aj-text.aj-text--small {
|
39
|
+
font-weight: 400;
|
40
|
+
font-size: 13px;
|
41
|
+
margin-top: 20px;
|
42
|
+
}
|
43
|
+
|
44
|
+
.aj-text {
|
45
|
+
font-family: 'Lato', 'Helvetica Nue', Helvetica, Arial sans-serif;
|
46
|
+
font-weight: 400;
|
47
|
+
font-size: 16px;
|
48
|
+
line-height: 1.4;
|
49
|
+
color: #333;
|
50
|
+
max-width: 600px;
|
51
|
+
-webkit-font-smoothing: antialiased;
|
52
|
+
-moz-osx-font-smoothing: grayscale;
|
53
|
+
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.004);
|
54
|
+
}
|
55
|
+
|
56
|
+
.aj-btn.aj-btn--blue:hover:enabled, a.aj-btn.aj-btn--blue:hover:enabled {
|
57
|
+
box-shadow: 0 3px 4px rgba(0, 50, 150, 0.3);
|
58
|
+
background-color: #2D7DAE;
|
59
|
+
}
|
60
|
+
|
61
|
+
.aj-btn:hover:enabled, a.aj-btn:hover:enabled {
|
62
|
+
cursor: pointer;
|
63
|
+
background-color: #efefef;
|
64
|
+
}
|
65
|
+
|
66
|
+
.aj-btn.aj-btn--blue:disabled {
|
67
|
+
opacity: 0.5;
|
68
|
+
}
|
69
|
+
|
70
|
+
.aj-btn.aj-btn--blue, a.aj-btn.aj-btn--blue {
|
71
|
+
font-family: "Lato", "Helvetica Nue", Helvetica, Arial sans-serif;
|
72
|
+
font-weight: 700;
|
73
|
+
background-color: #2D7DAE;
|
74
|
+
border: none;
|
75
|
+
font-size: 16px;
|
76
|
+
line-height: 27px;
|
77
|
+
text-align: left;
|
78
|
+
color: #ffffff;
|
79
|
+
border-radius: none;
|
80
|
+
border: 2px solid #2D7DAE;
|
81
|
+
}
|
82
|
+
|
83
|
+
.aj-btn, a.aj-btn {
|
84
|
+
height: 40px;
|
85
|
+
display: inline-flex;
|
86
|
+
align-items: center;
|
87
|
+
gap: 12px;
|
88
|
+
justify-content: center;
|
89
|
+
border-radius: 5px;
|
90
|
+
white-space: nowrap;
|
91
|
+
position: relative;
|
92
|
+
isolation: isolate;
|
93
|
+
padding: 0 12px;
|
94
|
+
text-decoration: none;
|
95
|
+
}
|
@@ -5,10 +5,10 @@ module AtomicLti
|
|
5
5
|
|
6
6
|
AUTHORIZATION_TRIES = 3
|
7
7
|
# Validates a token provided by an LTI consumer
|
8
|
-
def self.validate_token(
|
8
|
+
def self.validate_token(id_token)
|
9
9
|
# Get the iss value from the original request during the oidc call.
|
10
10
|
# Use that value to figure out which jwk we should use.
|
11
|
-
decoded_token = JWT.decode(
|
11
|
+
decoded_token = JWT.decode(id_token, nil, false)
|
12
12
|
|
13
13
|
iss = decoded_token.dig(0, "iss")
|
14
14
|
|
@@ -31,8 +31,8 @@ module AtomicLti
|
|
31
31
|
jwks
|
32
32
|
end
|
33
33
|
|
34
|
-
|
35
|
-
|
34
|
+
id_token_decoded, _keys = JWT.decode(id_token, nil, true, { algorithms: ["RS256"], jwks: jwk_loader })
|
35
|
+
id_token_decoded
|
36
36
|
end
|
37
37
|
|
38
38
|
def self.sign_tool_jwt(payload)
|
@@ -73,19 +73,26 @@ module AtomicLti
|
|
73
73
|
sign_tool_jwt(payload)
|
74
74
|
end
|
75
75
|
|
76
|
-
def self.request_token(iss:, deployment_id:)
|
76
|
+
def self.request_token(iss:, deployment_id:, scopes: nil)
|
77
77
|
deployment = AtomicLti::Deployment.find_by(iss: iss, deployment_id: deployment_id)
|
78
78
|
|
79
79
|
raise AtomicLti::Exceptions::NoLTIDeployment.new(iss: iss, deployment_id: deployment_id) if deployment.nil?
|
80
80
|
|
81
|
-
|
81
|
+
scopestr = if scopes
|
82
|
+
scopes.sort.join(" ")
|
83
|
+
else
|
84
|
+
AtomicLti.scopes
|
85
|
+
end
|
86
|
+
|
87
|
+
# Token is cached based on deployment id and requested scopes
|
88
|
+
cache_key = "#{deployment.cache_key}/#{Digest::SHA1.hexdigest(scopestr)}/services_authorization"
|
82
89
|
tries = 1
|
83
90
|
|
84
91
|
begin
|
85
92
|
authorization = Rails.cache.read(cache_key)
|
86
93
|
return authorization if authorization.present?
|
87
94
|
|
88
|
-
authorization = request_token_uncached(iss: iss, deployment_id: deployment_id)
|
95
|
+
authorization = request_token_uncached(iss: iss, deployment_id: deployment_id, scopes: scopestr)
|
89
96
|
|
90
97
|
# Subtract a few seconds so we don't use an expired token
|
91
98
|
expires_in = authorization["expires_in"].to_i - 10
|
@@ -109,13 +116,13 @@ module AtomicLti
|
|
109
116
|
authorization
|
110
117
|
end
|
111
118
|
|
112
|
-
def self.request_token_uncached(iss:, deployment_id:)
|
119
|
+
def self.request_token_uncached(iss:, deployment_id:, scopes:)
|
113
120
|
# Details here:
|
114
121
|
# https://www.imsglobal.org/spec/security/v1p0/#using-json-web-tokens-with-oauth-2-0-client-credentials-grant
|
115
122
|
body = {
|
116
123
|
grant_type: "client_credentials",
|
117
124
|
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
118
|
-
scope:
|
125
|
+
scope: scopes,
|
119
126
|
client_assertion: client_assertion(iss: iss, deployment_id: deployment_id),
|
120
127
|
}
|
121
128
|
headers = {
|
@@ -67,13 +67,13 @@ module AtomicLti
|
|
67
67
|
]
|
68
68
|
end
|
69
69
|
|
70
|
-
CANVAS_PUBLIC_LTI_KEYS_URL = "https://
|
71
|
-
CANVAS_OIDC_URL = "https://
|
72
|
-
CANVAS_AUTH_TOKEN_URL = "https://
|
70
|
+
CANVAS_PUBLIC_LTI_KEYS_URL = "https://sso.canvaslms.com/api/lti/security/jwks".freeze
|
71
|
+
CANVAS_OIDC_URL = "https://sso.canvaslms.com/api/lti/authorize_redirect".freeze
|
72
|
+
CANVAS_AUTH_TOKEN_URL = "https://sso.canvaslms.com/login/oauth2/token".freeze
|
73
73
|
|
74
|
-
CANVAS_BETA_PUBLIC_LTI_KEYS_URL = "https://
|
75
|
-
|
76
|
-
|
74
|
+
CANVAS_BETA_PUBLIC_LTI_KEYS_URL = "https://sso.beta.canvaslms.com/api/lti/security/jwks".freeze
|
75
|
+
CANVAS_BETA_OIDC_URL = "https://sso.beta.canvaslms.com/api/lti/authorize_redirect".freeze
|
76
|
+
CANVAS_BETA_AUTH_TOKEN_URL = "https://sso.beta.canvaslms.com/login/oauth2/token".freeze
|
77
77
|
|
78
78
|
CANVAS_SUBMISSION_TYPE = "https://canvas.instructure.com/lti/submission_type".freeze
|
79
79
|
|
@@ -115,6 +115,38 @@ module AtomicLti
|
|
115
115
|
MEMBER_CONTEXT_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/membership#Member".freeze
|
116
116
|
OFFICER_CONTEXT_ROLE = "http://purl.imsglobal.org/vocab/lis/v2/membership#Officer".freeze
|
117
117
|
|
118
|
+
ROLES = [
|
119
|
+
ADMINISTRATOR_SYSTEM_ROLE,
|
120
|
+
NONE_SYSTEM_ROLE,
|
121
|
+
ACCOUNT_ADMIN_SYSTEM_ROLE,
|
122
|
+
CREATOR_SYSTEM_ROLE,
|
123
|
+
SYS_ADMIN_SYSTEM_ROLE,
|
124
|
+
SYS_SUPPORT_SYSTEM_ROLE,
|
125
|
+
USER_SYSTEM_ROLE,
|
126
|
+
ADMINISTRATOR_INSTITUTION_ROLE,
|
127
|
+
FACULTY_INSTITUTION_ROLE,
|
128
|
+
GUEST_INSTITUTION_ROLE,
|
129
|
+
NONE_INSTITUTION_ROLE,
|
130
|
+
OTHER_INSTITUTION_ROLE,
|
131
|
+
STAFF_INSTITUTION_ROLE,
|
132
|
+
STUDENT_INSTITUTION_ROLE,
|
133
|
+
ALUMNI_INSTITUTION_ROLE,
|
134
|
+
INSTRUCTOR_INSTITUTION_ROLE,
|
135
|
+
LEARNER_INSTITUTION_ROLE,
|
136
|
+
MEMBER_INSTITUTION_ROLE,
|
137
|
+
MENTOR_INSTITUTION_ROLE,
|
138
|
+
OBSERVER_INSTITUTION_ROLE,
|
139
|
+
PROSPECTIVE_STUDENT_INSTITUTION_ROLE,
|
140
|
+
ADMINISTRATOR_CONTEXT_ROLE,
|
141
|
+
CONTENT_DEVELOPER_CONTEXT_ROLE,
|
142
|
+
INSTRUCTOR_CONTEXT_ROLE,
|
143
|
+
LEARNER_CONTEXT_ROLE,
|
144
|
+
MENTOR_CONTEXT_ROLE,
|
145
|
+
MANAGER_CONTEXT_ROLE,
|
146
|
+
MEMBER_CONTEXT_ROLE,
|
147
|
+
OFFICER_CONTEXT_ROLE,
|
148
|
+
].freeze
|
149
|
+
|
118
150
|
ADMINISTRATOR_ROLES = [
|
119
151
|
ADMINISTRATOR_SYSTEM_ROLE,
|
120
152
|
ACCOUNT_ADMIN_SYSTEM_ROLE,
|
@@ -1,7 +1,7 @@
|
|
1
1
|
module AtomicLti
|
2
2
|
module Exceptions
|
3
3
|
|
4
|
-
#
|
4
|
+
# LTI data related exceptions
|
5
5
|
class AtomicLtiException < StandardError
|
6
6
|
end
|
7
7
|
|
@@ -20,15 +20,9 @@ module AtomicLti
|
|
20
20
|
class StateError < AtomicLtiException
|
21
21
|
end
|
22
22
|
|
23
|
-
class OpenIDStateError < AtomicLtiException
|
24
|
-
end
|
25
|
-
|
26
23
|
class OpenIDRedirectError < AtomicLtiException
|
27
24
|
end
|
28
25
|
|
29
|
-
class JwtIssueError < AtomicLtiException
|
30
|
-
end
|
31
|
-
|
32
26
|
class LineItemMissing < LineItemError
|
33
27
|
end
|
34
28
|
|
@@ -50,18 +44,28 @@ module AtomicLti
|
|
50
44
|
end
|
51
45
|
end
|
52
46
|
|
53
|
-
|
54
|
-
|
47
|
+
# Authorization errors
|
48
|
+
class AtomicLtiAuthException < StandardError
|
49
|
+
end
|
50
|
+
|
51
|
+
class InvalidLTIToken < AtomicLtiAuthException
|
52
|
+
def initialize(msg = "Invalid LTI token provided")
|
55
53
|
super(msg)
|
56
54
|
end
|
57
55
|
end
|
58
56
|
|
59
|
-
class
|
60
|
-
|
57
|
+
class JwtIssueError < AtomicLtiAuthException
|
58
|
+
end
|
59
|
+
|
60
|
+
class NoLTIToken < AtomicLtiAuthException
|
61
|
+
def initialize(msg = "No LTI token provided")
|
61
62
|
super(msg)
|
62
63
|
end
|
63
64
|
end
|
64
65
|
|
66
|
+
class OpenIDStateError < AtomicLtiAuthException
|
67
|
+
end
|
68
|
+
|
65
69
|
# Not found exceptions
|
66
70
|
class AtomicLtiNotFoundException < StandardError
|
67
71
|
end
|
data/app/lib/atomic_lti/lti.rb
CHANGED
@@ -12,7 +12,7 @@ module AtomicLti
|
|
12
12
|
errors.push("LTI token is missing required field iss")
|
13
13
|
end
|
14
14
|
|
15
|
-
if decoded_token["sub"].blank?
|
15
|
+
if decoded_token["sub"].blank? && !AtomicLti.allow_anonymous_user
|
16
16
|
errors.push("LTI token is missing required field sub")
|
17
17
|
end
|
18
18
|
|
@@ -51,6 +51,14 @@ module AtomicLti
|
|
51
51
|
)
|
52
52
|
end
|
53
53
|
|
54
|
+
roles = decoded_token[AtomicLti::Definitions::ROLES_CLAIM]
|
55
|
+
if AtomicLti.role_enforcement_mode == AtomicLti::RoleEnforcementMode::STRICT && roles.is_a?(Array) && !roles.empty?
|
56
|
+
invalid_roles = roles - AtomicLti::Definitions::ROLES
|
57
|
+
if invalid_roles.length == roles.length
|
58
|
+
errors.push("LTI token has invalid roles: #{invalid_roles.join(", ")}")
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
54
62
|
if errors.length > 0
|
55
63
|
raise AtomicLti::Exceptions::InvalidLTIToken.new(errors.join(" "))
|
56
64
|
end
|
@@ -69,7 +77,7 @@ module AtomicLti
|
|
69
77
|
|
70
78
|
if decoded_token[AtomicLti::Definitions::TARGET_LINK_URI_CLAIM].blank?
|
71
79
|
errors.push(
|
72
|
-
"LTI token is missing required claim #{AtomicLti::Definitions::TARGET_LINK_URI_CLAIM}"
|
80
|
+
"LTI token is missing required claim #{AtomicLti::Definitions::TARGET_LINK_URI_CLAIM}",
|
73
81
|
)
|
74
82
|
end
|
75
83
|
|
@@ -77,19 +85,19 @@ module AtomicLti
|
|
77
85
|
target_link_uri = decoded_token[AtomicLti::Definitions::TARGET_LINK_URI_CLAIM]
|
78
86
|
if validate_target_link_url && target_link_uri != requested_target_link_uri
|
79
87
|
errors.push(
|
80
|
-
"LTI token target link uri '#{target_link_uri}' doesn't match url '#{requested_target_link_uri}'"
|
88
|
+
"LTI token target link uri '#{target_link_uri}' doesn't match url '#{requested_target_link_uri}'",
|
81
89
|
)
|
82
90
|
end
|
83
91
|
|
84
92
|
if decoded_token[AtomicLti::Definitions::RESOURCE_LINK_CLAIM].blank?
|
85
93
|
errors.push(
|
86
|
-
"LTI token is missing required claim #{AtomicLti::Definitions::RESOURCE_LINK_CLAIM}"
|
94
|
+
"LTI token is missing required claim #{AtomicLti::Definitions::RESOURCE_LINK_CLAIM}",
|
87
95
|
)
|
88
96
|
end
|
89
97
|
|
90
98
|
if decoded_token.dig(AtomicLti::Definitions::RESOURCE_LINK_CLAIM, "id").blank?
|
91
99
|
errors.push(
|
92
|
-
"LTI token is missing required field id from the claim #{AtomicLti::Definitions::RESOURCE_LINK_CLAIM}"
|
100
|
+
"LTI token is missing required field id from the claim #{AtomicLti::Definitions::RESOURCE_LINK_CLAIM}",
|
93
101
|
)
|
94
102
|
end
|
95
103
|
|
@@ -1,22 +1,34 @@
|
|
1
1
|
module AtomicLti
|
2
2
|
class OpenId
|
3
|
-
def self.
|
4
|
-
state
|
5
|
-
|
6
|
-
open_id_state.destroy
|
7
|
-
true
|
8
|
-
else
|
9
|
-
false
|
3
|
+
def self.validate_state(nonce, state)
|
4
|
+
if state.blank?
|
5
|
+
return false
|
10
6
|
end
|
11
|
-
|
12
|
-
|
13
|
-
|
7
|
+
|
8
|
+
open_id_state = AtomicLti::OpenIdState.find_by(state: state)
|
9
|
+
if !open_id_state
|
10
|
+
return false
|
11
|
+
end
|
12
|
+
|
13
|
+
open_id_state.destroy
|
14
|
+
|
15
|
+
# Check that the state hasn't expired
|
16
|
+
if open_id_state.created_at < 10.minutes.ago
|
17
|
+
return false
|
18
|
+
end
|
19
|
+
|
20
|
+
if nonce != open_id_state.nonce
|
21
|
+
return false
|
22
|
+
end
|
23
|
+
|
24
|
+
true
|
14
25
|
end
|
15
26
|
|
16
|
-
def self.
|
27
|
+
def self.generate_state
|
17
28
|
nonce = SecureRandom.hex(64)
|
18
|
-
|
19
|
-
AtomicLti::
|
29
|
+
state = SecureRandom.hex(32)
|
30
|
+
AtomicLti::OpenIdState.create!(nonce: nonce, state: state)
|
31
|
+
[nonce, state]
|
20
32
|
end
|
21
33
|
end
|
22
34
|
end
|
@@ -2,23 +2,24 @@ module AtomicLti
|
|
2
2
|
module Services
|
3
3
|
class Base
|
4
4
|
|
5
|
-
def initialize(
|
6
|
-
|
5
|
+
def initialize(id_token_decoded: nil, iss: nil, deployment_id: nil)
|
7
6
|
token_iss = nil
|
8
7
|
token_deployment_id = nil
|
9
8
|
|
10
|
-
if
|
11
|
-
token_iss =
|
12
|
-
token_deployment_id =
|
9
|
+
if id_token_decoded.present?
|
10
|
+
token_iss = id_token_decoded["iss"]
|
11
|
+
token_deployment_id = id_token_decoded[AtomicLti::Definitions::DEPLOYMENT_ID]
|
13
12
|
end
|
14
13
|
|
15
|
-
@
|
14
|
+
@id_token_decoded = id_token_decoded
|
16
15
|
@iss = iss || token_iss
|
17
16
|
@deployment_id = deployment_id || token_deployment_id
|
18
17
|
end
|
19
18
|
|
19
|
+
def scopes; end
|
20
|
+
|
20
21
|
def headers(options = {})
|
21
|
-
@token ||= AtomicLti::Authorization.request_token(iss: @iss, deployment_id: @deployment_id)
|
22
|
+
@token ||= AtomicLti::Authorization.request_token(iss: @iss, deployment_id: @deployment_id, scopes: scopes)
|
22
23
|
{
|
23
24
|
"Authorization" => "Bearer #{@token['access_token']}",
|
24
25
|
}.merge(options)
|
@@ -3,12 +3,17 @@ module AtomicLti
|
|
3
3
|
# Canvas API docs https://canvas.instructure.com/doc/api/line_items.html
|
4
4
|
class LineItems < AtomicLti::Services::Base
|
5
5
|
|
6
|
-
def endpoint(
|
7
|
-
url =
|
6
|
+
def endpoint(id_token_decoded)
|
7
|
+
url = id_token_decoded.dig(AtomicLti::Definitions::AGS_CLAIM, "lineitems")
|
8
8
|
raise AtomicLti::Exceptions::LineItemError, "Unable to access line items" unless url.present?
|
9
|
+
|
9
10
|
url
|
10
11
|
end
|
11
12
|
|
13
|
+
def scopes
|
14
|
+
@id_token_decoded&.dig(AtomicLti::Definitions::AGS_CLAIM, "scope")
|
15
|
+
end
|
16
|
+
|
12
17
|
# Helper method to generate a default set of attributes
|
13
18
|
def self.generate(
|
14
19
|
label:,
|
@@ -27,8 +32,8 @@ module AtomicLti
|
|
27
32
|
tag: tag,
|
28
33
|
startDateTime: start_date_time,
|
29
34
|
endDateTime: end_date_time,
|
35
|
+
resourceLinkId: resource_link_id,
|
30
36
|
}.compact
|
31
|
-
attrs["resourceLinkId"] = resource_link_id if resource_link_id
|
32
37
|
if external_tool_url
|
33
38
|
attrs[AtomicLti::Definitions::CANVAS_SUBMISSION_TYPE] = {
|
34
39
|
type: "external_tool",
|
@@ -42,14 +47,14 @@ module AtomicLti
|
|
42
47
|
self.class.generate(**attrs)
|
43
48
|
end
|
44
49
|
|
45
|
-
def self.can_manage_line_items?(
|
46
|
-
|
50
|
+
def self.can_manage_line_items?(id_token_decoded)
|
51
|
+
id_token_decoded.dig(AtomicLti::Definitions::AGS_CLAIM, "scope")&.
|
47
52
|
include?(AtomicLti::Definitions::AGS_SCOPE_LINE_ITEM)
|
48
53
|
end
|
49
54
|
|
50
|
-
def self.can_query_line_items?(
|
51
|
-
can_manage_line_items?(
|
52
|
-
|
55
|
+
def self.can_query_line_items?(id_token_decoded)
|
56
|
+
can_manage_line_items?(id_token_decoded) ||
|
57
|
+
id_token_decoded.dig(AtomicLti::Definitions::AGS_CLAIM, "scope").
|
53
58
|
include?(AtomicLti::Definitions::AGS_SCOPE_LINE_ITEM_READONLY)
|
54
59
|
end
|
55
60
|
|
@@ -57,7 +62,7 @@ module AtomicLti
|
|
57
62
|
# Canvas: https://canvas.beta.instructure.com/doc/api/line_items.html#method.lti/ims/line_items.index
|
58
63
|
def list(query = {})
|
59
64
|
accept = { "Accept" => "application/vnd.ims.lis.v2.lineitemcontainer+json" }
|
60
|
-
HTTParty.get(endpoint(@
|
65
|
+
HTTParty.get(endpoint(@id_token_decoded), headers: headers(accept), query: query)
|
61
66
|
end
|
62
67
|
|
63
68
|
# Get a specific line item
|
@@ -72,7 +77,7 @@ module AtomicLti
|
|
72
77
|
# Canvas: https://canvas.beta.instructure.com/doc/api/line_items.html#method.lti/ims/line_items.create
|
73
78
|
def create(attrs = nil)
|
74
79
|
content_type = { "Content-Type" => "application/vnd.ims.lis.v2.lineitem+json" }
|
75
|
-
HTTParty.post(endpoint(@
|
80
|
+
HTTParty.post(endpoint(@id_token_decoded), body: JSON.dump(attrs), headers: headers(content_type))
|
76
81
|
end
|
77
82
|
|
78
83
|
# Update a line item
|
@@ -2,12 +2,16 @@ module AtomicLti
|
|
2
2
|
module Services
|
3
3
|
class NamesAndRoles < AtomicLti::Services::Base
|
4
4
|
|
5
|
-
def initialize(
|
6
|
-
super(
|
5
|
+
def initialize(id_token_decoded:)
|
6
|
+
super(id_token_decoded: id_token_decoded)
|
7
|
+
end
|
8
|
+
|
9
|
+
def scopes
|
10
|
+
[AtomicLti::Definitions::NAMES_AND_ROLES_SCOPE]
|
7
11
|
end
|
8
12
|
|
9
13
|
def endpoint
|
10
|
-
url = @
|
14
|
+
url = @id_token_decoded.dig(AtomicLti::Definitions::NAMES_AND_ROLES_CLAIM, "context_memberships_url")
|
11
15
|
raise AtomicLti::Exceptions::NamesAndRolesError, "Unable to access names and roles" unless url.present?
|
12
16
|
|
13
17
|
url
|
@@ -19,15 +23,15 @@ module AtomicLti
|
|
19
23
|
url
|
20
24
|
end
|
21
25
|
|
22
|
-
def self.enabled?(
|
23
|
-
return false unless
|
26
|
+
def self.enabled?(id_token_decoded)
|
27
|
+
return false unless id_token_decoded&.dig(AtomicLti::Definitions::NAMES_AND_ROLES_CLAIM)
|
24
28
|
|
25
29
|
(AtomicLti::Definitions::NAMES_AND_ROLES_SERVICE_VERSIONS &
|
26
|
-
(
|
30
|
+
(id_token_decoded.dig(AtomicLti::Definitions::NAMES_AND_ROLES_CLAIM, "service_versions") || [])).present?
|
27
31
|
end
|
28
32
|
|
29
33
|
def valid?
|
30
|
-
self.class.enabled?(@
|
34
|
+
self.class.enabled?(@id_token_decoded)
|
31
35
|
end
|
32
36
|
|
33
37
|
# List names and roles
|
@@ -3,6 +3,10 @@ module AtomicLti
|
|
3
3
|
# Canvas API docs: https://canvas.instructure.com/doc/api/result.html
|
4
4
|
class Results < AtomicLti::Services::Base
|
5
5
|
|
6
|
+
def scopes
|
7
|
+
[AtomicLti::Definitions::AGS_SCOPE_RESULT]
|
8
|
+
end
|
9
|
+
|
6
10
|
def list(line_item_id)
|
7
11
|
url = "#{line_item_id}/results"
|
8
12
|
HTTParty.get(url, headers: headers)
|
@@ -5,18 +5,22 @@ module AtomicLti
|
|
5
5
|
|
6
6
|
attr_accessor :id
|
7
7
|
|
8
|
-
def initialize(
|
9
|
-
super(
|
8
|
+
def initialize(id_token_decoded: nil, iss: nil, deployment_id: nil, id: nil)
|
9
|
+
super(id_token_decoded: id_token_decoded, iss: iss, deployment_id: deployment_id)
|
10
10
|
@id = id
|
11
11
|
end
|
12
12
|
|
13
|
+
def scopes
|
14
|
+
[AtomicLti::Definitions::AGS_SCOPE_SCORE]
|
15
|
+
end
|
16
|
+
|
13
17
|
def endpoint
|
14
18
|
if id.blank?
|
15
19
|
raise ::AtomicLti::Exceptions::ScoreError,
|
16
20
|
"Invalid id or no id provided. Unable to access scores. id should be in the form of a url."
|
17
21
|
end
|
18
22
|
uri = URI(id)
|
19
|
-
uri.path = uri.path
|
23
|
+
uri.path = "#{uri.path}/scores"
|
20
24
|
uri
|
21
25
|
end
|
22
26
|
|
@@ -52,7 +56,7 @@ module AtomicLti
|
|
52
56
|
# values will require no action. Possible values are NotReady, Failed, Pending,
|
53
57
|
# PendingManual, FullyGraded
|
54
58
|
gradingProgress: grading_progress,
|
55
|
-
}
|
59
|
+
}.compact
|
56
60
|
end
|
57
61
|
|
58
62
|
def send(attrs)
|
@@ -1,6 +1,6 @@
|
|
1
1
|
module AtomicLti
|
2
2
|
class Jwk < ApplicationRecord
|
3
|
-
before_create :
|
3
|
+
before_create :ensure_keys_exist
|
4
4
|
|
5
5
|
def generate_keys
|
6
6
|
pkey = OpenSSL::PKey::RSA.generate(2048)
|
@@ -37,5 +37,14 @@ module AtomicLti
|
|
37
37
|
def self.current_jwk
|
38
38
|
self.last
|
39
39
|
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def ensure_keys_exist
|
44
|
+
if kid.blank?
|
45
|
+
generate_keys
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
40
49
|
end
|
41
50
|
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html lang="en">
|
3
|
+
<head>
|
4
|
+
<%= stylesheet_link_tag "atomic_lti/launch" %>
|
5
|
+
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet">
|
6
|
+
</head>
|
7
|
+
<body>
|
8
|
+
<div class="aj-main">
|
9
|
+
<div class="aj-error">
|
10
|
+
<h1 class="aj-title">
|
11
|
+
<i class="material-icons-outlined aj-icon" aria-hidden="true">error</i>
|
12
|
+
<%= @message %>
|
13
|
+
</h1>
|
14
|
+
</div>
|
15
|
+
</div>
|
16
|
+
</body>
|
17
|
+
</html>
|
@@ -0,0 +1,26 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html lang="en">
|
3
|
+
<head>
|
4
|
+
<style>
|
5
|
+
.hidden { display: none !important; }
|
6
|
+
</style>
|
7
|
+
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet">
|
8
|
+
<%= stylesheet_link_tag "atomic_lti/launch" %>
|
9
|
+
<%= javascript_include_tag "atomic_lti/init_app" %>
|
10
|
+
</head>
|
11
|
+
<body>
|
12
|
+
<noscript>
|
13
|
+
<div class="u-flex">
|
14
|
+
<i class="material-icons-outlined aj-icon" aria-hidden="true">warning</i>
|
15
|
+
<p class="aj-text">
|
16
|
+
You must have javascript enabled to use this application.
|
17
|
+
</p>
|
18
|
+
</div>
|
19
|
+
</noscript>
|
20
|
+
<div id="main-content">
|
21
|
+
</div>
|
22
|
+
<script type="text/javascript">
|
23
|
+
InitOIDCLaunch(window.SETTINGS);
|
24
|
+
</script>
|
25
|
+
</body>
|
26
|
+
</html>
|