atomic_lti 1.3.0 → 1.5.0
Sign up to get free protection for your applications and to get access to all the features.
- 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 +17 -10
- 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
|
|
@@ -16,7 +16,7 @@ module AtomicLti
|
|
16
16
|
|
17
17
|
platform = Platform.find_by(iss: iss)
|
18
18
|
|
19
|
-
raise AtomicLti::Exceptions::NoLTIPlatform(iss: iss, deployment_id: decoded_token.dig(0, "deployment_id")) if platform.nil?
|
19
|
+
raise AtomicLti::Exceptions::NoLTIPlatform.new(iss: iss, deployment_id: decoded_token.dig(0, "deployment_id")) if platform.nil?
|
20
20
|
|
21
21
|
cache_key = "#{iss}_jwks"
|
22
22
|
|
@@ -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(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.
|