atomic_lti 1.3.1 → 1.5.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 +4 -4
- data/README.md +14 -1
- 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 +9 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: eb887dd7dab6b360c7e0b351cd2d827f89407f68e677c5483275e3f429883ca1
|
4
|
+
data.tar.gz: feca333f5114334a966a00c79cb1be14a8992fe35ed87c8c82f2325c53cfcc91
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: cc620a997aeacea2edad4a45cd0c4fffc1480403953d622db3a996997c9d90011a5ef8ca967b519e2a018a757a636f5dc654781f940cfe16d83e9d825cd976e8
|
7
|
+
data.tar.gz: d1c174d1462537c3985a5d1c2141781b625f6ce75d2b7a55d4c5bf9122df5641ae7bbbacf90349ad3dcc11421e36b92da396cf4a748994d5f6ee3a3df4e3f553
|
data/README.md
CHANGED
@@ -15,7 +15,7 @@ $ bundle
|
|
15
15
|
|
16
16
|
Or install it yourself as:
|
17
17
|
```bash
|
18
|
-
$ gem install
|
18
|
+
$ gem install atomic_lti
|
19
19
|
```
|
20
20
|
|
21
21
|
Then install the migrations:
|
@@ -38,5 +38,18 @@ with the following contents. Adjust paths as needed.
|
|
38
38
|
AtomicLti.scopes = AtomicLti::Definitions.scopes.join(" ")
|
39
39
|
```
|
40
40
|
|
41
|
+
Add the middleware configuration to application.rb (assuming AtomicTenant is in use)
|
42
|
+
```
|
43
|
+
config.middleware.insert_before AtomicTenant::CurrentApplicationInstanceMiddleware, AtomicLti::OpenIdMiddleware
|
44
|
+
config.middleware.insert_before AtomicLti::OpenIdMiddleware, OidcCompatabilityMiddleware
|
45
|
+
config.middleware.insert_before AtomicLti::OpenIdMiddleware, AtomicLti::ErrorHandlingMiddleware
|
46
|
+
```
|
47
|
+
|
48
|
+
## Building javascript
|
49
|
+
Run esbuild:
|
50
|
+
```
|
51
|
+
yarn build
|
52
|
+
```
|
53
|
+
|
41
54
|
## License
|
42
55
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
@@ -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>
|
@@ -1,15 +1,28 @@
|
|
1
1
|
<!DOCTYPE html>
|
2
2
|
<html lang="en">
|
3
3
|
<head>
|
4
|
-
<
|
5
|
-
|
6
|
-
</script>
|
4
|
+
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet">
|
5
|
+
<%= stylesheet_link_tag "atomic_lti/launch" %>
|
7
6
|
</head>
|
8
7
|
<body>
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
8
|
+
<noscript>
|
9
|
+
<div class="u-flex aj-centered-message">
|
10
|
+
<i class="material-icons-outlined aj-icon" aria-hidden="true">warning</i>
|
11
|
+
<p class="aj-text">
|
12
|
+
You must have javascript enabled to use this application.
|
13
|
+
</p>
|
14
|
+
</div>
|
15
|
+
</noscript>
|
16
|
+
<form action="<%= @launch_url -%>" method="POST">
|
17
|
+
<% @launch_params.each do |name, value| -%>
|
18
|
+
<%= hidden_field_tag(name, value) %>
|
19
|
+
<% end -%>
|
20
|
+
</form>
|
21
|
+
</div>
|
22
|
+
<script>
|
23
|
+
window.addEventListener("load", () => {
|
24
|
+
document.forms[0].submit();
|
25
|
+
});
|
26
|
+
</script>
|
14
27
|
</body>
|
15
28
|
</html>
|
data/db/seeds.rb
CHANGED
@@ -2,16 +2,16 @@
|
|
2
2
|
AtomicLti::Jwk.find_or_create_by(domain: nil)
|
3
3
|
|
4
4
|
# Add some platforms
|
5
|
-
AtomicLti::Platform.create_with(
|
6
|
-
jwks_url:
|
7
|
-
token_url:
|
8
|
-
oidc_url:
|
5
|
+
AtomicLti::Platform.create_with(
|
6
|
+
jwks_url: AtomicLti::Definitions::CANVAS_PUBLIC_LTI_KEYS_URL,
|
7
|
+
token_url: AtomicLti::Definitions::CANVAS_AUTH_TOKEN_URL,
|
8
|
+
oidc_url: AtomicLti::Definitions::CANVAS_OIDC_URL,
|
9
9
|
).find_or_create_by(iss: "https://canvas.instructure.com")
|
10
10
|
|
11
11
|
AtomicLti::Platform.create_with(
|
12
|
-
jwks_url:
|
13
|
-
token_url:
|
14
|
-
oidc_url:
|
12
|
+
jwks_url: AtomicLti::Definitions::CANVAS_BETA_PUBLIC_LTI_KEYS_URL,
|
13
|
+
token_url: AtomicLti::Definitions::CANVAS_BETA_AUTH_TOKEN_URL,
|
14
|
+
oidc_url: AtomicLti::Definitions::CANVAS_BETA_OIDC_URL,
|
15
15
|
).find_or_create_by(iss: "https://canvas-beta.instructure.com")
|
16
16
|
|
17
17
|
|
@@ -26,4 +26,4 @@ AtomicTenant::PinnedPlatformGuid.create(iss: "https://canvas.instructure.com", p
|
|
26
26
|
# deployment_id: "21089:1f5e1ee417cb2b17f86a1232122452ab3f6188f7",
|
27
27
|
# application_instance_id: 5,
|
28
28
|
# created_at: Tue, 16 Aug 2022 16:05:20.848365000 UTC +00:00,
|
29
|
-
# updated_at: Tue, 16 Aug 2022 16:05:20.848365000 UTC +00:00>
|
29
|
+
# updated_at: Tue, 16 Aug 2022 16:05:20.848365000 UTC +00:00>
|
@@ -4,7 +4,7 @@ module AtomicLti
|
|
4
4
|
@app = app
|
5
5
|
end
|
6
6
|
|
7
|
-
def render_error(
|
7
|
+
def render_error(status, message)
|
8
8
|
format = "text/plain"
|
9
9
|
body = message
|
10
10
|
|
@@ -12,22 +12,30 @@ module AtomicLti
|
|
12
12
|
end
|
13
13
|
|
14
14
|
def render(status, body, format)
|
15
|
-
[
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
15
|
+
[
|
16
|
+
status,
|
17
|
+
{
|
18
|
+
"Content-Type" => "#{format}; charset=\"UTF-8\"",
|
19
|
+
"Content-Length" => body.bytesize.to_s,
|
20
|
+
},
|
21
|
+
[body],
|
22
|
+
]
|
21
23
|
end
|
22
24
|
|
23
25
|
def call(env)
|
24
26
|
@app.call(env)
|
25
|
-
|
27
|
+
rescue JWT::ExpiredSignature
|
28
|
+
render_error(401, "The launch has expired. Please launch the application again.")
|
29
|
+
rescue JWT::DecodeError
|
30
|
+
render_error(401, "The launch token is invalid.")
|
31
|
+
rescue AtomicLti::Exceptions::NoLTIToken
|
32
|
+
render_error(401, "Invalid launch. Please launch the application again.")
|
33
|
+
rescue AtomicLti::Exceptions::AtomicLtiAuthException => e
|
34
|
+
render_error(401, "Invalid LTI launch. Please launch the application again. #{e.message}")
|
26
35
|
rescue AtomicLti::Exceptions::AtomicLtiNotFoundException => e
|
27
|
-
render_error(
|
28
|
-
|
36
|
+
render_error(404, e.message)
|
29
37
|
rescue AtomicLti::Exceptions::AtomicLtiException => e
|
30
|
-
render_error(
|
38
|
+
render_error(500, "Invalid LTI launch. Please launch the application again. #{e.message}")
|
31
39
|
end
|
32
40
|
end
|
33
41
|
end
|
@@ -1,4 +1,8 @@
|
|
1
1
|
module AtomicLti
|
2
|
+
# This is the same prefix used in the npm package. There's not a great way to share constants between ruby and npm.
|
3
|
+
# Don't change it unless you change it in the Javascript as well.
|
4
|
+
OPEN_ID_COOKIE_PREFIX = "open_id_".freeze
|
5
|
+
|
2
6
|
class OpenIdMiddleware
|
3
7
|
def initialize(app)
|
4
8
|
@app = app
|
@@ -17,26 +21,86 @@ module AtomicLti
|
|
17
21
|
end
|
18
22
|
|
19
23
|
def handle_init(request)
|
20
|
-
|
24
|
+
platform = AtomicLti::Platform.find_by(iss: request.params["iss"])
|
25
|
+
if !platform
|
26
|
+
raise AtomicLti::Exceptions::NoLTIPlatform.new(iss: request.params["iss"])
|
27
|
+
end
|
28
|
+
|
29
|
+
nonce, state = AtomicLti::OpenId.generate_state
|
30
|
+
|
31
|
+
headers = { "Content-Type" => "text/html" }
|
32
|
+
Rack::Utils.set_cookie_header!(
|
33
|
+
headers, "#{OPEN_ID_COOKIE_PREFIX}storage",
|
34
|
+
{ value: "1", path: "/", max_age: 365.days, http_only: false, secure: true, same_site: "None" }
|
35
|
+
)
|
36
|
+
Rack::Utils.set_cookie_header!(
|
37
|
+
headers, "#{OPEN_ID_COOKIE_PREFIX}#{state}",
|
38
|
+
{ value: 1, path: "/", max_age: 1.minute, http_only: false, secure: true, same_site: "None" }
|
39
|
+
)
|
21
40
|
|
22
41
|
redirect_uri = [request.base_url, AtomicLti.oidc_redirect_path].join
|
42
|
+
response_url = build_oidc_response(request, state, nonce, redirect_uri)
|
43
|
+
|
44
|
+
if request.cookies.present? || !AtomicLti.enforce_csrf_protection
|
45
|
+
# we know cookies will work, so redirect
|
46
|
+
headers["Location"] = response_url
|
23
47
|
|
24
|
-
|
25
|
-
|
48
|
+
[302, headers, ["Found"]]
|
49
|
+
else
|
50
|
+
# cookies might not work, so render our javascript form
|
51
|
+
if request.params["lti_storage_target"].present? && AtomicLti.use_post_message_storage
|
52
|
+
lti_storage_params = build_lti_storage_params(request, platform)
|
53
|
+
end
|
26
54
|
|
27
|
-
|
28
|
-
|
29
|
-
|
55
|
+
html = ApplicationController.renderer.render(
|
56
|
+
:html,
|
57
|
+
layout: false,
|
58
|
+
template: "atomic_lti/shared/init",
|
59
|
+
assigns: {
|
60
|
+
settings: {
|
61
|
+
state: state,
|
62
|
+
responseUrl: response_url,
|
63
|
+
ltiStorageParams: lti_storage_params,
|
64
|
+
relaunchInitUrl: relaunch_init_url(request),
|
65
|
+
privacyPolicyUrl: AtomicLti.privacy_policy_url,
|
66
|
+
privacyPolicyMessage: AtomicLti.privacy_policy_message,
|
67
|
+
openIdCookiePrefix: OPEN_ID_COOKIE_PREFIX,
|
68
|
+
},
|
69
|
+
},
|
70
|
+
)
|
71
|
+
|
72
|
+
[200, headers, [html]]
|
73
|
+
end
|
30
74
|
end
|
31
75
|
|
32
|
-
def
|
76
|
+
def validate_launch(request, validate_target_link_url)
|
77
|
+
# Validate and decode id_token
|
33
78
|
raise AtomicLti::Exceptions::NoLTIToken if request.params["id_token"].blank?
|
34
79
|
|
35
|
-
|
36
|
-
|
37
|
-
|
80
|
+
id_token_decoded = AtomicLti::Authorization.validate_token(request.params["id_token"])
|
81
|
+
|
82
|
+
raise AtomicLti::Exceptions::InvalidLTIToken.new if id_token_decoded.nil?
|
83
|
+
|
84
|
+
# Validate id token contents
|
85
|
+
AtomicLti::Lti.validate!(id_token_decoded, request.url, validate_target_link_url)
|
86
|
+
|
87
|
+
# Check for the state cookie
|
88
|
+
state_verified = false
|
89
|
+
state = request.params["state"]
|
90
|
+
if request.cookies["open_id_#{state}"]
|
91
|
+
state_verified = true
|
92
|
+
end
|
38
93
|
|
39
|
-
|
94
|
+
# Validate the state and nonce
|
95
|
+
if !AtomicLti::OpenId.validate_state(id_token_decoded["nonce"], state)
|
96
|
+
raise AtomicLti::Exceptions::OpenIDStateError.new("Invalid OIDC state.")
|
97
|
+
end
|
98
|
+
|
99
|
+
[id_token_decoded, state, state_verified]
|
100
|
+
end
|
101
|
+
|
102
|
+
def handle_redirect(request)
|
103
|
+
id_token_decoded, _state, _state_verified = validate_launch(request, false)
|
40
104
|
|
41
105
|
uri = URI(request.url)
|
42
106
|
# Technically the target_link_uri is not required and the certification suite
|
@@ -44,25 +108,26 @@ module AtomicLti
|
|
44
108
|
# but at least for the certification suite we have to have a backup default
|
45
109
|
# value that can be set in the configuration of Atomic LTI using
|
46
110
|
# the default_deep_link_path
|
47
|
-
target_link_uri =
|
111
|
+
target_link_uri = id_token_decoded[AtomicLti::Definitions::TARGET_LINK_URI_CLAIM] ||
|
48
112
|
File.join("#{uri.scheme}://#{uri.host}", AtomicLti.default_deep_link_path)
|
49
113
|
|
50
|
-
redirect_params = {
|
51
|
-
state: request.params["state"],
|
52
|
-
id_token: request.params["id_token"],
|
53
|
-
}
|
54
114
|
html = ApplicationController.renderer.render(
|
55
115
|
:html,
|
56
116
|
layout: false,
|
57
117
|
template: "atomic_lti/shared/redirect",
|
58
|
-
assigns: {
|
118
|
+
assigns: {
|
119
|
+
launch_params: request.params,
|
120
|
+
launch_url: target_link_uri,
|
121
|
+
},
|
59
122
|
)
|
60
123
|
|
61
124
|
[200, { "Content-Type" => "text/html" }, [html]]
|
62
125
|
end
|
63
126
|
|
64
127
|
def matches_redirect?(request)
|
65
|
-
|
128
|
+
if AtomicLti.oidc_redirect_path.blank?
|
129
|
+
raise AtomicLti::Exceptions::ConfigurationError.new("AtomicLti.oidc_redirect_path is not configured")
|
130
|
+
end
|
66
131
|
|
67
132
|
redirect_uri = URI.parse(AtomicLti.oidc_redirect_path)
|
68
133
|
redirect_path_params = if redirect_uri.query
|
@@ -87,35 +152,42 @@ module AtomicLti
|
|
87
152
|
end
|
88
153
|
|
89
154
|
def handle_lti_launch(env, request)
|
90
|
-
|
91
|
-
state = request.params["state"]
|
92
|
-
url = request.url
|
155
|
+
id_token_decoded, state, state_verified = validate_launch(request, true)
|
93
156
|
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
157
|
+
id_token = request.params["id_token"]
|
158
|
+
update_install(id_token: id_token_decoded)
|
159
|
+
update_platform_instance(id_token: id_token_decoded)
|
160
|
+
update_deployment(id_token: id_token_decoded)
|
161
|
+
update_lti_context(id_token: id_token_decoded)
|
162
|
+
|
163
|
+
errors = id_token_decoded.dig(AtomicLti::Definitions::TOOL_PLATFORM_CLAIM, "errors")
|
164
|
+
if errors.present? && !errors["errors"].empty?
|
165
|
+
Rails.logger.error("Detected errors in lti launch: #{errors}, id_token: #{id_token}")
|
166
|
+
end
|
102
167
|
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
168
|
+
env["atomic.validated.decoded_id_token"] = id_token_decoded
|
169
|
+
env["atomic.validated.id_token"] = id_token
|
170
|
+
|
171
|
+
platform = AtomicLti::Platform.find_by!(iss: id_token_decoded["iss"])
|
172
|
+
if request.params["lti_storage_target"].present? && AtomicLti.use_post_message_storage
|
173
|
+
lti_storage_params = build_lti_storage_params(request, platform)
|
174
|
+
# Add the values needed to do client side validate to the environment
|
175
|
+
env["atomic.validated.state_validation"] = {
|
176
|
+
state: state,
|
177
|
+
lti_storage_params: lti_storage_params,
|
178
|
+
verified_by_cookie: state_verified,
|
179
|
+
}
|
180
|
+
end
|
107
181
|
|
108
|
-
|
109
|
-
env["atomic.validated.id_token"] = id_token
|
182
|
+
@app.call(env)
|
110
183
|
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
end
|
184
|
+
# Delete the state cookie
|
185
|
+
status, headers, body = @app.call(env)
|
186
|
+
# Rack::Utils.delete_cookie_header(headers, "#{OPEN_ID_COOKIE_PREFIX}#{state}")
|
187
|
+
[status, headers, body]
|
116
188
|
end
|
117
189
|
|
118
|
-
def error!(body = "Error", status = 500, headers = {"Content-Type" => "text/html"})
|
190
|
+
def error!(body = "Error", status = 500, headers = { "Content-Type" => "text/html" })
|
119
191
|
[status, headers, [body]]
|
120
192
|
end
|
121
193
|
|
@@ -134,6 +206,19 @@ module AtomicLti
|
|
134
206
|
|
135
207
|
protected
|
136
208
|
|
209
|
+
def render_error(status, message)
|
210
|
+
html = ApplicationController.renderer.render(
|
211
|
+
:html,
|
212
|
+
layout: false,
|
213
|
+
template: "atomic_lti/shared/error",
|
214
|
+
assigns: {
|
215
|
+
message: message || "There was an error during the launch. Please try again.",
|
216
|
+
},
|
217
|
+
)
|
218
|
+
|
219
|
+
[status || 404, { "Content-Type" => "text/html" }, [html]]
|
220
|
+
end
|
221
|
+
|
137
222
|
def update_platform_instance(id_token:)
|
138
223
|
if id_token[AtomicLti::Definitions::TOOL_PLATFORM_CLAIM].present? &&
|
139
224
|
id_token.dig(AtomicLti::Definitions::TOOL_PLATFORM_CLAIM, "guid").present?
|
@@ -222,32 +307,18 @@ module AtomicLti
|
|
222
307
|
)
|
223
308
|
end
|
224
309
|
|
225
|
-
def
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
token = false
|
232
|
-
|
233
|
-
begin
|
234
|
-
token = AtomicLti::Authorization.validate_token(id_token)
|
235
|
-
rescue JWT::DecodeError => e
|
236
|
-
Rails.logger.error("Unable to decode jwt: #{e}, #{e.backtrace}")
|
237
|
-
return false
|
238
|
-
end
|
239
|
-
|
240
|
-
return false if token.nil?
|
241
|
-
|
242
|
-
AtomicLti::Lti.validate!(token, url, true)
|
243
|
-
|
244
|
-
token
|
310
|
+
def relaunch_init_url(request)
|
311
|
+
uri = URI.parse(request.url)
|
312
|
+
uri.fragment = uri.query = nil
|
313
|
+
params = request.params
|
314
|
+
params.delete("lti_storage_target")
|
315
|
+
[uri.to_s, "?", params.to_query].join
|
245
316
|
end
|
246
317
|
|
247
318
|
def build_oidc_response(request, state, nonce, redirect_uri)
|
248
319
|
platform = AtomicLti::Platform.find_by(iss: request.params["iss"])
|
249
320
|
if !platform
|
250
|
-
raise AtomicLti::Exceptions::NoLTIPlatform.new(iss: request.params[
|
321
|
+
raise AtomicLti::Exceptions::NoLTIPlatform.new("No platform was found for iss: #{request.params['iss']}")
|
251
322
|
end
|
252
323
|
|
253
324
|
uri = URI.parse(platform.oidc_url)
|
@@ -268,5 +339,13 @@ module AtomicLti
|
|
268
339
|
|
269
340
|
[uri.to_s, "?", auth_params.to_query].join
|
270
341
|
end
|
342
|
+
|
343
|
+
def build_lti_storage_params(request, platform)
|
344
|
+
{
|
345
|
+
target: request.params["lti_storage_target"],
|
346
|
+
originSupportBroken: !AtomicLti.set_post_message_origin,
|
347
|
+
platformOIDCUrl: platform.oidc_url,
|
348
|
+
}
|
349
|
+
end
|
271
350
|
end
|
272
351
|
end
|
data/lib/atomic_lti/version.rb
CHANGED
data/lib/atomic_lti.rb
CHANGED
@@ -4,6 +4,7 @@ require "atomic_lti/open_id_middleware"
|
|
4
4
|
require "atomic_lti/error_handling_middleware"
|
5
5
|
require_relative "../app/lib/atomic_lti/definitions"
|
6
6
|
require_relative "../app/lib/atomic_lti/exceptions"
|
7
|
+
require_relative "../app/lib/atomic_lti/role_enforcement_mode"
|
7
8
|
module AtomicLti
|
8
9
|
|
9
10
|
# Set this to true to scope context_id's to the ISS rather than
|
@@ -18,7 +19,33 @@ module AtomicLti
|
|
18
19
|
mattr_accessor :target_link_path_prefixes
|
19
20
|
mattr_accessor :default_deep_link_path
|
20
21
|
mattr_accessor :jwt_secret
|
21
|
-
mattr_accessor :scopes
|
22
|
+
mattr_accessor :scopes, default: AtomicLti::Definitions.scopes.join(" ")
|
23
|
+
|
24
|
+
# Set to true to enforce CSRF protection, either via cookies or postMessage
|
25
|
+
mattr_accessor :enforce_csrf_protection, default: true
|
26
|
+
|
27
|
+
# Set to true to use LTI postMessage storage for csrf token storage
|
28
|
+
# with this enabled we can operate without cookies
|
29
|
+
mattr_accessor :use_post_message_storage, default: true
|
30
|
+
|
31
|
+
# Set to true to set the targetOrigin on postMessage calls. The LTI spec
|
32
|
+
# requires this, but Canvas doesn't currently support it.
|
33
|
+
mattr_accessor :set_post_message_origin, default: false
|
34
|
+
|
35
|
+
mattr_accessor :privacy_policy_url, default: "#"
|
36
|
+
mattr_accessor :privacy_policy_message, default: nil
|
37
|
+
|
38
|
+
# https://www.imsglobal.org/spec/lti/v1p3#anonymous-launch-case
|
39
|
+
# 'anonymous' here means that the launch does not include a 'sub' field. In
|
40
|
+
# Canvas, this means the user is not logged in at all. If you enable this
|
41
|
+
# option, you will likely have to adjust application code to accommodate
|
42
|
+
mattr_accessor :allow_anonymous_user, default: false
|
43
|
+
|
44
|
+
# https://www.imsglobal.org/spec/lti/v1p3#role-vocabularies
|
45
|
+
# Determines how strictly to enforce the role vocabulary. The options are:
|
46
|
+
# - "DEFAULT" which means that unknown roles are allowed to be the only roles in the token.
|
47
|
+
# - "STRICT" which means that unknown roles are not allowed to be the only roles in the token.
|
48
|
+
mattr_accessor :role_enforcement_mode, default: AtomicLti::RoleEnforcementMode::DEFAULT
|
22
49
|
|
23
50
|
def self.get_deployments(iss:, deployment_ids:)
|
24
51
|
AtomicLti::Deployment.where(iss: iss, deployment_id: deployment_ids)
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: atomic_lti
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.5.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Matt Petro
|
@@ -10,7 +10,7 @@ authors:
|
|
10
10
|
autorequire:
|
11
11
|
bindir: bin
|
12
12
|
cert_chain: []
|
13
|
-
date: 2023-
|
13
|
+
date: 2023-08-16 00:00:00.000000000 Z
|
14
14
|
dependencies:
|
15
15
|
- !ruby/object:Gem::Dependency
|
16
16
|
name: pg
|
@@ -56,8 +56,10 @@ files:
|
|
56
56
|
- app/assets/config/atomic_lti_manifest.js
|
57
57
|
- app/assets/stylesheets/atomic_lti/application.css
|
58
58
|
- app/assets/stylesheets/atomic_lti/jwks.css
|
59
|
+
- app/assets/stylesheets/atomic_lti/launch.css
|
59
60
|
- app/controllers/atomic_lti/jwks_controller.rb
|
60
61
|
- app/helpers/atomic_lti/launch_helper.rb
|
62
|
+
- app/javascript/atomic_lti/init_app.js
|
61
63
|
- app/jobs/atomic_lti/application_job.rb
|
62
64
|
- app/lib/atomic_lti/auth_token.rb
|
63
65
|
- app/lib/atomic_lti/authorization.rb
|
@@ -68,6 +70,7 @@ files:
|
|
68
70
|
- app/lib/atomic_lti/lti.rb
|
69
71
|
- app/lib/atomic_lti/open_id.rb
|
70
72
|
- app/lib/atomic_lti/params.rb
|
73
|
+
- app/lib/atomic_lti/role_enforcement_mode.rb
|
71
74
|
- app/lib/atomic_lti/services/base.rb
|
72
75
|
- app/lib/atomic_lti/services/line_items.rb
|
73
76
|
- app/lib/atomic_lti/services/names_and_roles.rb
|
@@ -85,6 +88,8 @@ files:
|
|
85
88
|
- app/models/atomic_lti/platform.rb
|
86
89
|
- app/models/atomic_lti/platform_instance.rb
|
87
90
|
- app/views/atomic_lti/launches/index.html.erb
|
91
|
+
- app/views/atomic_lti/shared/error.html.erb
|
92
|
+
- app/views/atomic_lti/shared/init.html.erb
|
88
93
|
- app/views/atomic_lti/shared/redirect.html.erb
|
89
94
|
- app/views/layouts/atomic_lti/application.html.erb
|
90
95
|
- config/routes.rb
|
@@ -96,6 +101,7 @@ files:
|
|
96
101
|
- db/migrate/20220428175423_create_atomic_lti_oauth_states.rb
|
97
102
|
- db/migrate/20220503003528_create_atomic_lti_jwks.rb
|
98
103
|
- db/migrate/20221010140920_create_open_id_state.rb
|
104
|
+
- db/migrate/20230726040941_add_state_to_open_id_state.rb
|
99
105
|
- db/seeds.rb
|
100
106
|
- lib/atomic_lti.rb
|
101
107
|
- lib/atomic_lti/engine.rb
|
@@ -124,7 +130,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
124
130
|
- !ruby/object:Gem::Version
|
125
131
|
version: '0'
|
126
132
|
requirements: []
|
127
|
-
rubygems_version: 3.
|
133
|
+
rubygems_version: 3.4.15
|
128
134
|
signing_key:
|
129
135
|
specification_version: 4
|
130
136
|
summary: AtomicLti implements the LTI Advantage specification.
|