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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a219867c2a1f19737d0222b0896e30a28144310a6032f1f6419443af37006304
4
- data.tar.gz: ee72503b3f1066f5e3a5ea710da6d818eb2680d79d60e805c828b8f34ef95917
3
+ metadata.gz: eb887dd7dab6b360c7e0b351cd2d827f89407f68e677c5483275e3f429883ca1
4
+ data.tar.gz: feca333f5114334a966a00c79cb1be14a8992fe35ed87c8c82f2325c53cfcc91
5
5
  SHA512:
6
- metadata.gz: 49fd684fa99b65a02402d968b2c53741af0ca19594f0a9ab083cc6d9e3e3a2780caeba0f4de3b31db200dcb8fa745d9b1771b0eae1ef7c025ad68baff5e49fb6
7
- data.tar.gz: 8670e8c13dec604b3af371ad7bff7fb7102be8aac32e32ae0e3ebca80718546e1cab5dfb559f18455bddcd2535032310e6be6b12ad568e9d74b4dc1c29ba25c6
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 atomic_tenant
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
+ }
@@ -0,0 +1,3 @@
1
+ import { InitOIDCLaunch } from '@atomicjolt/lti-client/lti/init';
2
+
3
+ InitOIDCLaunch(window.SETTINGS);
@@ -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(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(token, nil, false)
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
- lti_token, _keys = JWT.decode(token, nil, true, { algorithms: ["RS256"], jwks: jwk_loader })
35
- lti_token
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
- cache_key = "#{deployment.cache_key_with_version}/services_authorization"
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: AtomicLti.scopes,
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://canvas.instructure.com/api/lti/security/jwks".freeze
71
- CANVAS_OIDC_URL = "https://canvas.instructure.com/api/lti/authorize_redirect".freeze
72
- CANVAS_AUTH_TOKEN_URL = "https://canvas.instructure.com/login/oauth2/token".freeze
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://canvas.beta.instructure.com/api/lti/security/jwks".freeze
75
- CANVAS_BETA_AUTH_TOKEN_URL = "https://canvas.beta.instructure.com/login/oauth2/token".freeze
76
- CANVAS_BETA_OIDC_URL = "https://canvas.beta.instructure.com/api/lti/authorize_redirect".freeze
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
- # General exceptions
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
- class NoLTIToken < AtomicLtiException
54
- def initialize(msg = "No LTI token provided")
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 InvalidLTIToken < AtomicLtiException
60
- def initialize(msg = "Invalid LTI token provided")
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
@@ -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.validate_open_id_state(state)
4
- state = AtomicLti::AuthToken.decode(state)[0]
5
- if open_id_state = AtomicLti::OpenIdState.find_by(nonce: state["nonce"])
6
- open_id_state.destroy
7
- true
8
- else
9
- false
3
+ def self.validate_state(nonce, state)
4
+ if state.blank?
5
+ return false
10
6
  end
11
- rescue StandardError => e
12
- Rails.logger.info("Error decoding token: #{e} - #{e.backtrace}")
13
- false
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.state
27
+ def self.generate_state
17
28
  nonce = SecureRandom.hex(64)
18
- AtomicLti::OpenIdState.create!(nonce: nonce)
19
- AtomicLti::AuthToken.issue_token({ nonce: nonce })
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
@@ -3,8 +3,8 @@ module AtomicLti
3
3
  class Params
4
4
  attr_reader :token
5
5
 
6
- def initialize(lti_token)
7
- @token = lti_token.with_indifferent_access
6
+ def initialize(id_token_decoded)
7
+ @token = id_token_decoded.with_indifferent_access
8
8
  end
9
9
 
10
10
  def lti_advantage?
@@ -0,0 +1,8 @@
1
+ module AtomicLti
2
+ module RoleEnforcementMode
3
+ # Unkown roles are allowed to be the only role in the roles claim
4
+ DEFAULT = "DEFAULT".freeze
5
+ # Unkown roles are not allowed to be the only roles in the roles claim
6
+ STRICT = "STRICT".freeze
7
+ end
8
+ end
@@ -2,23 +2,24 @@ module AtomicLti
2
2
  module Services
3
3
  class Base
4
4
 
5
- def initialize(lti_token: nil, iss: nil, deployment_id: nil)
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 lti_token.present?
11
- token_iss = lti_token.dig('iss')
12
- token_deployment_id = lti_token.dig(AtomicLti::Definitions::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
- @lti_token = lti_token
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(lti_token)
7
- url = lti_token.dig(AtomicLti::Definitions::AGS_CLAIM, "lineitems")
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?(lti_token)
46
- lti_token.dig(AtomicLti::Definitions::AGS_CLAIM, "scope")&.
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?(lti_token)
51
- can_manage_line_items?(lti_token) ||
52
- lti_token.dig(AtomicLti::Definitions::AGS_CLAIM, "scope").
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(@lti_token), headers: headers(accept), query: query)
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(@lti_token), body: JSON.dump(attrs), headers: headers(content_type))
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(lti_token:)
6
- super(lti_token: lti_token)
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 = @lti_token.dig(AtomicLti::Definitions::NAMES_AND_ROLES_CLAIM, "context_memberships_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?(lti_token)
23
- return false unless lti_token&.dig(AtomicLti::Definitions::NAMES_AND_ROLES_CLAIM)
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
- (lti_token.dig(AtomicLti::Definitions::NAMES_AND_ROLES_CLAIM, "service_versions") || [])).present?
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?(@lti_token)
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(lti_token: nil, iss:nil, deployment_id: nil, id: nil)
9
- super(lti_token: lti_token, iss: iss, deployment_id: deployment_id)
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+'/scores'
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 :generate_keys
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
@@ -1,5 +1,6 @@
1
1
  module AtomicLti
2
2
  class OpenIdState < ApplicationRecord
3
3
  validates :nonce, presence: true, uniqueness: true
4
+ validates :state, presence: true, uniqueness: true
4
5
  end
5
6
  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
- <script type="text/javascript">
5
- window.onload=function(){document.forms[0].submit();};
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
- <form action="<%= @launch_url -%>" method="POST">
10
- <% @launch_params.each do |name, value| -%>
11
- <%= hidden_field_tag(name, value) %>
12
- <% end -%>
13
- </form>
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>
@@ -0,0 +1,6 @@
1
+ class AddStateToOpenIdState < ActiveRecord::Migration[7.0]
2
+ def change
3
+ add_column :atomic_lti_open_id_states, :state, :string
4
+ add_index :atomic_lti_open_id_states, :state, unique: true
5
+ end
6
+ end
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: "https://canvas.instructure.com/api/lti/security/jwks",
7
- token_url: "https://canvas.instructure.com/login/oauth2/token",
8
- oidc_url: "https://canvas.instructure.com/api/lti/authorize_redirect"
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: "https://canvas-beta.instructure.com/api/lti/security/jwks",
13
- token_url: "https://canvas-beta.instructure.com/login/oauth2/token",
14
- oidc_url: "https://canvas-beta.instructure.com/api/lti/authorize_redirect",
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(env, status, message)
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
- [status,
16
- {
17
- "Content-Type" => "#{format}; charset=\"UTF-8\"",
18
- "Content-Length" => body.bytesize.to_s,
19
- },
20
- [body]]
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(env, 404, e.message)
28
-
36
+ render_error(404, e.message)
29
37
  rescue AtomicLti::Exceptions::AtomicLtiException => e
30
- render_error(env, 500, e.message)
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
- nonce = SecureRandom.hex(64)
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
- state = AtomicLti::OpenId.state
25
- url = build_oidc_response(request, state, nonce, redirect_uri)
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
- headers = { "Location" => url, "Content-Type" => "text/html" }
28
- Rack::Utils.set_cookie_header!(headers, "open_id_state", state)
29
- [302, headers, ["Found"]]
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 handle_redirect(request)
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
- lti_token = AtomicLti::Authorization.validate_token(
36
- request.params["id_token"],
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
- AtomicLti::Lti.validate!(lti_token)
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 = lti_token[AtomicLti::Definitions::TARGET_LINK_URI_CLAIM] ||
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: { launch_params: redirect_params, launch_url: target_link_uri },
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
- raise AtomicLti::Exceptions::ConfigurationError.new("AtomicLti.oidc_redirect_path is not configured") if AtomicLti.oidc_redirect_path.blank?
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
- id_token = request.params["id_token"]
91
- state = request.params["state"]
92
- url = request.url
155
+ id_token_decoded, state, state_verified = validate_launch(request, true)
93
156
 
94
- payload = valid_token(state: state, id_token: id_token, url: url)
95
- if payload
96
- decoded_jwt = payload
97
-
98
- update_install(id_token: decoded_jwt)
99
- update_platform_instance(id_token: decoded_jwt)
100
- update_deployment(id_token: decoded_jwt)
101
- update_lti_context(id_token: decoded_jwt)
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
- errors = decoded_jwt.dig(AtomicLti::Definitions::TOOL_PLATFORM_CLAIM, "errors")
104
- if errors.present? && !errors["errors"].empty?
105
- Rails.logger.error("Detected errors in lti launch: #{errors}, id_token: #{id_token}")
106
- end
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
- env["atomic.validated.decoded_id_token"] = decoded_jwt
109
- env["atomic.validated.id_token"] = id_token
182
+ @app.call(env)
110
183
 
111
- @app.call(env)
112
- else
113
- Rails.logger.info("Invalid lti launch: id_token: #{payload} - id_token: #{id_token} - state: #{state} - url: #{url}")
114
- [401, {}, ["Invalid Lti Launch"]]
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 valid_token(state:, id_token:, url:)
226
- # Validate the state by checking the database for the nonce
227
- valid_state = AtomicLti::OpenId.validate_open_id_state(state)
228
-
229
- return false if !valid_state
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["iss"])
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
@@ -1,3 +1,3 @@
1
1
  module AtomicLti
2
- VERSION = '1.3.0'
2
+ VERSION = "1.5.0".freeze
3
3
  end
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.3.0
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-03-22 00:00:00.000000000 Z
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.1.6
133
+ rubygems_version: 3.4.15
128
134
  signing_key:
129
135
  specification_version: 4
130
136
  summary: AtomicLti implements the LTI Advantage specification.