panda_pal 5.14.0.beta7 → 5.15.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: 762a0d4a45e1c9e5c94e9a67303b9adfd7f437742d3b000b5216cda9f901ae0e
4
- data.tar.gz: fc798c502cfc2218ef88f8286c05c9434e7e8b5a6835a2d643619b11e1ba8c21
3
+ metadata.gz: dcf0b49dce1c842893ddd32353c984df374c0355fcc3508f15fe430bd04d93df
4
+ data.tar.gz: af35bc87c1179a41aaf836282863806e58d7af534c6a6cc8523e7fe8e47b088a
5
5
  SHA512:
6
- metadata.gz: 3b2d5f5342348f0e99dfbb8382c00e396e5f1c82f8d22721b5e0f725b65d0c3877c27b17b6a74ef292619840a05ff284de0f37161290a2ce08199cb87a31d997
7
- data.tar.gz: 1883e69179dc592d45b243e944934df02e298315fd7e5806b17a406da4557f9b9c7ffc5de7cb4559d7b687f97e09154fbd95de220a9f482fec37fd1b7def7fed
6
+ metadata.gz: caaf1b79881bb2e3fc0670bba8c9148e808243ba569a143a5c940bbace0e5458732e4236a2b1c0c92b61f7820040118243a32547d35c74fcf2f6155e308a668a
7
+ data.tar.gz: d4c2901a2ec51d3c40b06cc3d727a3af346e93f8a7239f7d8b1d0e906fd5d7ac1c3e41b3b256d45091aad19898eeea0efe89a9491336125169ed5dd8e591119a
@@ -1,5 +1,7 @@
1
1
  module PandaPal
2
2
  class Session < PandaPalRecord
3
+ class SessionNonceMismatch < StandardError; end
4
+
3
5
  belongs_to :panda_pal_organization, class_name: 'PandaPal::Organization', optional: true
4
6
 
5
7
  after_initialize do
@@ -9,6 +11,129 @@ module PandaPal
9
11
 
10
12
  delegate :dig, to: :data
11
13
 
14
+ def self.for_request(request, params = request.params, enforce_tenant: true)
15
+ return request.instance_variable_get(:@panda_pal_session) if request.instance_variable_defined?(:@panda_pal_session)
16
+
17
+ panda_token = extract_panda_token(request, params)
18
+ request.instance_variable_set(:@panda_pal_session, for_panda_token(panda_token, request: request, enforce_tenant: enforce_tenant))
19
+ end
20
+
21
+ def self.for_panda_token(panda_token, request: nil, validate: true, enforce_tenant: true)
22
+ return nil unless panda_token.present?
23
+
24
+ # <B64({ v: TOKEN FORMAT, o: ORG ID, s: SESSION ID, typ: TYPE, exp?: EXPIRE, usig?: URL SIGNATURE, nnc?: NONCE })>.<HMAC(<B64HEADER>) | KEY>
25
+ # TYPE = KEY | T-URI | T-IP | T-NONCE | T-EXP
26
+
27
+ found_session = nil
28
+
29
+ header, sig = panda_token.split('.')
30
+ decoded_header = JSON.parse(Base64.urlsafe_decode64(header))
31
+
32
+ org_id = decoded_header['o'].to_i
33
+ session_id = decoded_header['s']
34
+ type = decoded_header['typ']
35
+
36
+ tenant = "public"
37
+
38
+ if org_id > 0
39
+ org = PandaPal::Organization.find(org_id)
40
+ tenant = org.tenant_name
41
+ end
42
+
43
+ Apartment::Tenant.switch!(tenant) if enforce_tenant == :switch
44
+
45
+ if enforce_tenant && Apartment::Tenant.current != tenant
46
+ raise SessionNonceMismatch, "Session Not Found"
47
+ end
48
+
49
+ Apartment::Tenant.switch(tenant) do
50
+ if type == 'KEY'
51
+ found_session = PandaPal::Session.find_by(session_secret: sig)
52
+ return found_session unless validate
53
+
54
+ raise SessionNonceMismatch, "Session Not Found" unless found_session.present?
55
+ raise SessionNonceMismatch, "Session Not Found" if session_id && found_session.id != session_id
56
+ else
57
+ session_record = PandaPal::Session.find_by(id: session_id)
58
+ return session_record unless validate
59
+
60
+ raise SessionNonceMismatch, "Session Not Found" unless session_record.present?
61
+
62
+ # Validate the header against the signature
63
+ raise SessionNonceMismatch, "Invalid Signature" unless session_record.validate_signature(header, sig)
64
+
65
+ # Check expiration
66
+ if (expr = decoded_header['exp']).present?
67
+ raise SessionNonceMismatch, "Expired" unless expr > DateTime.now.iso8601
68
+ end
69
+
70
+ if type == "T-URI" || type == "T-URL"
71
+ # Signed URLs only support GET requests
72
+ raise SessionNonceMismatch, "Invalid Method" unless request.method.to_s.upcase == "GET"
73
+
74
+ # Verify the signature against the request URL
75
+ # The usig doesn't _need_ to be signed (since it's part of the payload that is already signed),
76
+ # but this was an easy way to make the value a constant length. Any basic hashing function could solve this,
77
+ # but the HMAC implementation was already available.
78
+ to_sign = format_url_for_signing(request.url)
79
+ raise SessionNonceMismatch, "Invalid Signature" unless session_record.validate_signature(to_sign, decoded_header['usig'])
80
+
81
+ # TODO Support single-use tokens via Redis?
82
+
83
+ found_session = session_record
84
+ elsif type == "T-NONCE"
85
+ raise SessionNonceMismatch, "Invalid Nonce" unless session_record.data[:link_nonce] == decoded_header['nnc']
86
+ found_session = session_record
87
+ found_session.data[:link_nonce] = nil
88
+ elsif type == "T-IP"
89
+ raise SessionNonceMismatch, "Invalid IP" unless decoded_header["ip"] == request.remote_ip
90
+ found_session = session_record
91
+ elsif type == "T-EXP"
92
+ # Expiration itself is checked above, but enforce that an expiration is set
93
+ raise SessionNonceMismatch, "Invalid Expiration" unless decoded_header["exp"].present?
94
+ found_session = session_record
95
+ else
96
+ raise SessionNonceMismatch, "Invalid Panda Token Type"
97
+ end
98
+ end
99
+ end
100
+
101
+ raise SessionNonceMismatch, "Session Not Found" unless found_session.present?
102
+
103
+ found_session
104
+ end
105
+
106
+ def self.extract_panda_token(request, params = request.params)
107
+ headers = request.respond_to?(:headers) ? request.headers : request.env
108
+ token = headers['HTTP_X_PANDA_TOKEN'] || headers['X-Panda-Token']
109
+
110
+ token ||= begin
111
+ if (auth_header = headers['HTTP_AUTHORIZATION'] || headers['Authorization']).present?
112
+ match = auth_header.match(/Bearer panda:(.+)/)
113
+ match ||= auth_header.match(/token=(.+)/) # Legacy Support
114
+ token = match[1] if match && match[1].include?('.')
115
+ end
116
+ end
117
+
118
+ token ||= params["panda_token"]
119
+ token ||= params["session_token"] if params["session_token"]&.include?('.') # Legacy Support
120
+ token ||= params["session_key"] if params["session_key"]&.include?('.') # Legacy Support
121
+
122
+ token.presence
123
+ end
124
+
125
+ def self.format_url_for_signing(uri)
126
+ uri = URI.parse(uri) if uri.is_a?(String)
127
+
128
+ query = Rack::Utils.parse_query(uri.query)
129
+ query.delete("panda_token")
130
+ query.delete("session_key")
131
+ query.delete("session_token")
132
+ uri.query = Rack::Utils.build_query(query)
133
+
134
+ "#{uri.path}?#{uri.query}"
135
+ end
136
+
12
137
  def session_key
13
138
  # <B64({ v: TOKEN FORMAT, o: ORG ID, s: SESSION ID, typ: "KEY" })>.<SESSION SECRET>
14
139
  payload = {
@@ -26,10 +151,11 @@ module PandaPal
26
151
  end
27
152
 
28
153
  def sign_value(data)
29
- OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), signing_key, data)
154
+ OpenSSL::HMAC.base64digest(OpenSSL::Digest.new('sha256'), signing_key, data)
30
155
  end
31
156
 
32
157
  def validate_signature(data, signature)
158
+ # TODO Constant time
33
159
  signature == sign_value(data)
34
160
  end
35
161
 
@@ -106,11 +232,13 @@ module PandaPal
106
232
  val
107
233
  end
108
234
 
235
+ # Retrieve the User's Role Labels in the Launch Context
109
236
  def canvas_role_labels
110
237
  labels = get_lti_cust_param('custom_canvas_role')
111
238
  labels.is_a?(String) ? labels.split(',') : []
112
239
  end
113
240
 
241
+ # Retrieve the User's Role Labels in the specified Account, defaulting to the Root Account
114
242
  def canvas_account_role_labels(account = 'self')
115
243
  account = 'self' if account.to_s == "root"
116
244
  account = account.canvas_id if account.respond_to?(:canvas_id)
@@ -181,7 +181,7 @@ module Apartment
181
181
  # to effectively disable this patch for that model.
182
182
  if (adapter = Thread.current[:apartment_adapter]) && adapter.is_a?(Apartment::Adapters::PostgresMultiDBSchemaAdapter) && !adapter.is_excluded_model?(self)
183
183
  shard, schema = Apartment::Tenant.split_tenant(adapter.current)
184
- return "apt:#{shard}" unless shard == "default"
184
+ return "#{shard}" unless shard == "default"
185
185
  end
186
186
 
187
187
  pre_apartment_current_shard
@@ -220,18 +220,19 @@ Apartment.configure do |config|
220
220
  end
221
221
 
222
222
  Rails.application.config.middleware.use Apartment::Elevators::Generic, lambda { |request|
223
- if match = request.path.match(/\/(?:orgs?|organizations?)\/(\d+)/)
224
- PandaPal::Organization.find_by(id: match[1]).try(:tenant_name)
225
- elsif match = request.path.match(/\/(?:orgs?|organizations?|o)\/(\w+)/)
226
- PandaPal::Organization.find_by(name: match[1]).try(:tenant_name)
227
- elsif (panda_token = PandaPal::Helpers::SessionReplacement.extract_panda_token(request)).present?
228
- header, sig = panda_token.split('.')
229
- # This is just switching the tenant. Auth should be performed elsewhere (eg via `forbid_access_if_lacking_session`/`valid_session?`).
230
- decoded_header = JSON.parse(Base64.urlsafe_decode64(header))
231
- PandaPal::Organization.find_by(id: decoded_header['o']).try(:tenant_name)
232
- elsif request.path.start_with?('/rails/active_storage/blobs/')
233
- PandaPal::Organization.find_by(id: request.params['organization_id']).try(:tenant_name)
234
- end
223
+ tenant = if match = request.path.match(/\/(?:orgs?|organizations?)\/(\d+)/)
224
+ PandaPal::Organization.find_by(id: match[1]).try(:tenant_name)
225
+ elsif match = request.path.match(/\/(?:orgs?|organizations?|o)\/(\w+)/)
226
+ PandaPal::Organization.find_by(name: match[1]).try(:tenant_name)
227
+ elsif (panda_token = PandaPal::Session.extract_panda_token(request)).present?
228
+ header, sig = panda_token.split('.')
229
+ # This is just switching the tenant. Auth should be performed elsewhere (eg via `forbid_access_if_lacking_session`/`valid_session?`).
230
+ decoded_header = JSON.parse(Base64.urlsafe_decode64(header))
231
+ PandaPal::Organization.find_by(id: decoded_header['o']).try(:tenant_name)
232
+ elsif request.path.start_with?('/rails/active_storage/blobs/')
233
+ PandaPal::Organization.find_by(id: request.params['organization_id']).try(:tenant_name)
234
+ end
235
+ tenant || 'public'
235
236
  }
236
237
 
237
238
  module PandaPal::Plugins::ApartmentCache
@@ -142,7 +142,7 @@ module PandaPal::Helpers
142
142
  return false unless current_panda_session.panda_pal_organization_id == current_organization.id
143
143
  return false unless Apartment::Tenant.current == current_organization.tenant_name
144
144
  true
145
- rescue SessionNonceMismatch
145
+ rescue PandaPal::Session::SessionNonceMismatch
146
146
  false
147
147
  end
148
148
 
@@ -1,6 +1,4 @@
1
1
  module PandaPal::Helpers
2
- class SessionNonceMismatch < StandardError; end
3
-
4
2
  module SessionReplacement
5
3
  extend ActiveSupport::Concern
6
4
 
@@ -43,64 +41,8 @@ module PandaPal::Helpers
43
41
  def current_panda_session
44
42
  return @current_session if defined?(@current_session)
45
43
 
46
- if (panda_token = SessionReplacement.extract_panda_token(request, params)).present?
47
- # <B64({ v: TOKEN FORMAT, o: ORG ID, s: SESSION ID, typ: TYPE, exp?: EXPIRE, usig?: URL SIGNATURE, nnc?: NONCE })>.<HMAC(<B64HEADER>) | KEY>
48
- # TYPE = KEY | T-URI | T-IP | T-NONCE | T-EXP
49
-
50
- header, sig = panda_token.split('.')
51
- decoded_header = JSON.parse(Base64.urlsafe_decode64(header))
52
-
53
- org_id = decoded_header['o'].to_i
54
- session_id = decoded_header['s']
55
- type = decoded_header['typ']
56
-
57
- # raise SessionNonceMismatch, "Session Not Found" unless org_id < 1 || org_id == current_organization.id
58
-
59
- if type == 'KEY'
60
- @current_session = PandaPal::Session.find_by(session_secret: sig)
61
- raise SessionNonceMismatch, "Session Not Found" unless @current_session.present?
62
- raise SessionNonceMismatch, "Session Not Found" if session_id && @current_session.id != session_id
63
- else
64
- session_record = PandaPal::Session.find_by(id: session_id)
65
- raise SessionNonceMismatch, "Session Not Found" unless session_record.present?
66
-
67
- # Validate the header against the signature
68
- raise SessionNonceMismatch, "Invalid Signature" unless session_record.validate_signature(header, sig)
69
-
70
- # Check expiration
71
- if (expr = decoded_header['exp']).present?
72
- raise SessionNonceMismatch, "Expired" unless expr > DateTime.now.iso8601
73
- end
74
-
75
- if type == "T-URI"
76
- # Signed URLs only support GET requests
77
- raise SessionNonceMismatch, "Invalid Method" unless request.method == :get
78
-
79
- # Verify the signature against the request URL
80
- # The usig doesn't _need_ to be signed (since it's part of the payload that is already signed),
81
- # but this was an easy way to make the value a constant length. Any basic hashing function could solve this,
82
- # but the HMAC implementation was already available.
83
- resigned = generate_signed_url(session_record.signing_key, request.url)
84
- raise SessionNonceMismatch, "Invalid Signature" unless resigned == decoded_header['usig']
85
-
86
- # TODO Support single-use tokens via Redis?
87
-
88
- @current_session = session_record
89
- elsif type == "T-NONCE"
90
- raise SessionNonceMismatch, "Invalid Nonce" unless session_record.data[:link_nonce] == decoded_header['nnc']
91
- @current_session = session_record
92
- @current_session.data[:link_nonce] = nil
93
- elsif type == "T-IP"
94
- raise SessionNonceMismatch, "Invalid IP" unless decoded_header["ip"] == request.remote_ip
95
- @current_session = session_record
96
- elsif type == "T-EXP"
97
- # Expiration itself is checked above, but enforce that an expiration is set
98
- raise SessionNonceMismatch, "Invalid Expiration" unless decoded_header["exp"].present?
99
- @current_session = session_record
100
- end
101
- end
102
-
103
- raise SessionNonceMismatch, "Session Not Found" unless @current_session.present?
44
+ if (panda_token = PandaPal::Session.extract_panda_token(request, params)).present?
45
+ @current_session = PandaPal::Session.for_panda_token(panda_token, request: request)
104
46
 
105
47
  elsif (session_key = params[:session_key] || session_key_header || flash[:session_key] || session[:session_key]).present?
106
48
  # Legacy-format session_keys
@@ -189,9 +131,10 @@ module PandaPal::Helpers
189
131
 
190
132
  if type == 'url'
191
133
  raise StandardError, "URL is required" unless url.present?
134
+ to_sign = PandaPal::Session.format_url_for_signing(url)
192
135
  payload.merge!(
193
136
  typ: 'T-URI',
194
- usig: generate_signed_url(current_session.signing_key, url.to_s),
137
+ usig: current_session.sign_value(to_sign),
195
138
  )
196
139
  else
197
140
  @cached_link_nonces ||= {}
@@ -237,22 +180,9 @@ module PandaPal::Helpers
237
180
  15
238
181
  end
239
182
 
183
+ # @deprecated
240
184
  def self.extract_panda_token(request, params = request.params)
241
- headers = request.respond_to?(:headers) ? request.headers : request.env
242
- token = headers['HTTP_X_PANDA_TOKEN'] || headers['X-Panda-Token']
243
-
244
- token ||= begin
245
- if (auth_header = headers['HTTP_AUTHORIZATION'] || headers['Authorization']).present?
246
- match = auth_header.match(/Bearer panda:(.+)/)
247
- match ||= auth_header.match(/token=(.+)/) # Legacy Support
248
- token = match[1] if match && match[1].include?('.')
249
- end
250
- end
251
-
252
- token ||= params["panda_token"]
253
- token ||= params["session_token"] if params["session_token"]&.include?('.') # Legacy Support
254
- token ||= params["session_key"] if params["session_key"]&.include?('.') # Legacy Support
255
- token.presence
185
+ PandaPal::Session.extract_panda_token(request, params)
256
186
  end
257
187
 
258
188
  private
@@ -262,6 +192,7 @@ module PandaPal::Helpers
262
192
  @session_cryptor ||= ActiveSupport::MessageEncryptor.new(secret_key_base[0..31])
263
193
  end
264
194
 
195
+ # @deprecated
265
196
  def session_key_header
266
197
  if match = request.headers['Authorization'].try(:match, /token=(.+)/)
267
198
  match[1]
@@ -273,19 +204,6 @@ module PandaPal::Helpers
273
204
  save_session if @current_session && session_changed?
274
205
  end
275
206
 
276
- def generate_signed_url(key, uri)
277
- uri = URI.parse(uri) if uri.is_a?(String)
278
- signable = "#{uri.path}?#{uri.query}"
279
- OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), key, signable)
280
- end
281
-
282
- def validate_url_signature(key, uri, signature)
283
- uri = URI.parse(uri) if uri.is_a?(String)
284
- signable = uri.path + '?' + uri.query
285
- signable.gsub!(/[&?](session_key|session_token|panda_token)=.*?(&.*)?$/, "")
286
- signature == OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), key, signable)
287
- end
288
-
289
207
  def monkeypatch_flash
290
208
  if valid_session? && (value = current_session['flashes']).present?
291
209
  flashes = value["flashes"]
@@ -1,3 +1,3 @@
1
1
  module PandaPal
2
- VERSION = "5.14.0.beta7"
2
+ VERSION = "5.15.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: panda_pal
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.14.0.beta7
4
+ version: 5.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Instructure CustomDev
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2025-04-24 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rails
@@ -282,7 +281,6 @@ dependencies:
282
281
  - - '='
283
282
  - !ruby/object:Gem::Version
284
283
  version: 2.7.1
285
- description:
286
284
  email:
287
285
  - pseng@instructure.com
288
286
  executables: []
@@ -400,7 +398,6 @@ homepage: http://instructure.com
400
398
  licenses:
401
399
  - MIT
402
400
  metadata: {}
403
- post_install_message:
404
401
  rdoc_options: []
405
402
  require_paths:
406
403
  - lib
@@ -415,8 +412,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
415
412
  - !ruby/object:Gem::Version
416
413
  version: '0'
417
414
  requirements: []
418
- rubygems_version: 3.5.16
419
- signing_key:
415
+ rubygems_version: 3.6.9
420
416
  specification_version: 4
421
417
  summary: LTI mountable engine
422
418
  test_files:
@@ -433,6 +429,7 @@ test_files:
433
429
  - spec/dummy/app/controllers/application_controller.rb
434
430
  - spec/dummy/app/helpers/application_helper.rb
435
431
  - spec/dummy/app/views/layouts/application.html.erb
432
+ - spec/dummy/config.ru
436
433
  - spec/dummy/config/application.rb
437
434
  - spec/dummy/config/boot.rb
438
435
  - spec/dummy/config/database.yml
@@ -450,7 +447,6 @@ test_files:
450
447
  - spec/dummy/config/locales/en.yml
451
448
  - spec/dummy/config/routes.rb
452
449
  - spec/dummy/config/secrets.yml
453
- - spec/dummy/config.ru
454
450
  - spec/dummy/db/schema.rb
455
451
  - spec/dummy/db/test2_schema.rb
456
452
  - spec/dummy/public/404.html