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