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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f4e630de3811dceac9fa0db319f298f3d0f2c2511624466df133e398ab87ad4a
4
- data.tar.gz: afa911b83546448c4a14e0ce27f8e4fb773e4b44c261362fe4e777f49a5fa023
3
+ metadata.gz: eb887dd7dab6b360c7e0b351cd2d827f89407f68e677c5483275e3f429883ca1
4
+ data.tar.gz: feca333f5114334a966a00c79cb1be14a8992fe35ed87c8c82f2325c53cfcc91
5
5
  SHA512:
6
- metadata.gz: 4a6dd4c17f5b458c2c4adaa8cfd152ad047c906f09d5d6746b05fe13b5efd0f31841a2237e3d65ac9325a74dd26811768c6fabddc536c27e22a4394ce0e19a95
7
- data.tar.gz: 1f64fb3ed49714598867763504f9609c9769cc34b2e292da18bc5f34bdc97ea081443b836034b56ed1f7054749991999d51ba9e04880171bf79c3d11c9f14253
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
 
@@ -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.new(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.1'
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.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-03-28 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.