panda_pal 5.13.4 → 5.14.0.beta1

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: 394e082d23ea7e24a5a4fd854191ad33f34fb52acffa2dc805fd27f64119c683
4
- data.tar.gz: 1a50f36b6aba5a71c4622824fc7fd47a6fc2331ab643831b75c45f28162f461a
3
+ metadata.gz: 7dca28a2b62bd99b38f8e03e56ff2ca800a9e4783e3758e3f111d724033ecac9
4
+ data.tar.gz: 50cca8089331c5e3ced69508bd36ce38d1950f36e24c32ddcfe8013c5d203b08
5
5
  SHA512:
6
- metadata.gz: 833f7ec08c7778a5fcbbb810a2c887068aec0e55fb1131c24adee99ae6477c20f56181c90a37424fb31401fe280ee2340d330bf07635d367c4d104ce2fde2a3d
7
- data.tar.gz: a03e818dcd00ca287f1454cfb29aa55e2037239d904dff16fd774c3b20d5b96803c699c899b20a29c32dece85c507c3077a1dd7bd312f219854cd0994f62ee40
6
+ metadata.gz: c311c03565a44e28c27824ee5577844ea6c0cf5b1270135dcd32f888aab9f8b06466af1c55d445d194c006d89cedad547dc84b70e6655cb5c2962d0968a7e030
7
+ data.tar.gz: 576674c1989edfdfb1f6bd6f8368c52be0bd9b6b92cf76cdc8ce6903650aec5fb41b4514f411ca2024fc6bc500d6e438a9e0fa901c4380acb6d3981eca082575
data/README.md CHANGED
@@ -239,44 +239,69 @@ Controllers will automatically have access to a number of methods that can be he
239
239
  Because of the ever-increasing security around `<iframe>`s and cookies, a custom session implementation has been rolled into `PandaPal`.
240
240
  The implementation is mostly passive, but requires some changes and caveats as opposed to the native Rails implementation.
241
241
 
242
- The current session object can be accessed from any controller using `current_session`. This object isn't the actual data container (as `session` _is_ in Rails).
243
- Session data can be accessed and modified using `current_session.data[:key]` or `current_session_data[:key]`.
242
+ The current session object can be accessed from any controller using `current_session`.
243
+ Session data can be accessed and modified using `current_session[:key]`.
244
244
 
245
- Because cookies are unreliable, it becomes the responsibility of the LTI developer to ensure that a custom "cookie" is passed to the frontend with each response
246
- and returned to the backend with each request, otherwise the backend won't be able to access the current session. There are a few ways to do this.
245
+ Because cookies are unreliable, it becomes the responsibility of the LTI developer to ensure that a custom "cookie" is passed to the frontend with each response and returned to the backend with each request, otherwise the backend won't be able to access the current session. There are a few ways to do this.
247
246
 
248
247
  Generally, the method for passing the session "cookie" to the frontend looks something like:
249
248
  ```html
250
249
  <meta name="session_key" content="<%= current_session.session_key %>">
251
250
  ```
252
251
 
253
- ### Returning to the backend
254
- The `session_key` can be passed to the backend in multiple ways:
255
- 1. (Recommended) Via the `Authorization` Header (usually defining it as a default in your AJAX/XHR/`fetch` library):
256
- ```http
257
- Authorization: token=SESSION_KEY_HERE
258
- ```
259
- 2. Via a `session_key` parameter in a `POST` request (Useful for native HTML `<form>`s).
260
- 3. (Used for Dev, but not highly discouraged in Prod) via a `GET`/URL Query parameter: `?session_key=SESSION_KEY_HERE`.
261
-
262
- The `session_key` does not contain any secrets itself, so it is safe to pass to the frontend, but it is encouraged to keep it away from the
263
- end-user as much as possible because it should not be shared.
252
+ ### Panda Token Format
253
+ The `session_key` used to be a single value secret, but as of PandaPal 5.14, it is formatted as such (and referred to as a "Full-Key Panda Token"):
254
+ `<Base64({ v: TOKEN FORMAT, o: ORG ID, s: SESSION ID, typ: "KEY" })>.<SESSION SECRET>`
255
+
256
+ Additionally as of 5.14, "session_tokens" were dissolved into the Panda Token format (and now referred to as "Limited-Use Panda Tokens").
257
+ These Limited-Use tokens are formatted as such:
258
+ `<Base64({ v: TOKEN FORMAT, o: ORG ID, s: SESSION ID, typ: "X", ... })>.<HMAC(key: SESSION_SECRET, content: Base64(...))>`
259
+
260
+ Currently, there is support for these `typ`es (also noted with any other params to be included in the B64 content):
261
+ - `T-URL` `{ usig: HMAC(key: SESSION_SECRET, content: URL_PATH_AND_QUERY) }` - Use to authenticate `GET`ting a single URL.
262
+ - May be generated on the client using `@inst_proserv/toolkit/panda_tokens`
263
+ - `T-NONCE` `{ nnc: NONCE }` - Stores a single-use nonce in the Session. Can only be used once and will expire as soon as another `T-NONCE` token is created in another request.
264
+ - `T-IP` `{ ip: 1.2.3.4 }` - Token restricted to use by a single IP address
265
+ - `T-EXP` `{ exp: }` - Basic, time-limited token
266
+
267
+ All Limited-Use tokens may also include these parameters:
268
+ - `exp` - ISO8601 Expiration timestamp
269
+
270
+ ### Passing the Session
271
+ PandaPal looks for Session information in multiple places (in order of precedence):
272
+ - `X-Panda-Token` Header
273
+ - `Authorization: Bearer <PANDA TOKEN>` Header
274
+ - `Authorization: token=<PANDA TOKEN>` Header (Deprecated; legacy-use only). Supports use of legacy or new-format
275
+ - `panda_token` param
276
+ - `session_key` param (Deprecated; legacy-use only). Supports use of legacy or new-format
277
+ - `session_token` param (Deprecated; legacy-use only)
278
+
279
+ Use of one of the supported HTTP Headers is preferred.
280
+ `panda_token=<Full-Use Token>` param may be used for (non-`GET`) HTML `<form>`s.
281
+ `panda_token=<Limited-Use Token>` param should be used for `GET` requests in-which you cannot control a Header.
264
282
 
265
283
  ### Redirecting and Multi-Page Applications
266
284
  Special instructions must be followed when using `link_to` or `redirect_to` to ensure that the Session is passed correctly:
267
285
 
268
286
  Add a `session_token:` (notice the use of _`_token`_ as opposed to _`_key`_!) parameter when using `link_to` or `redirect_to`:
269
287
  ```ruby
270
- link_to "Link Name", somewhere_else_path(session_token: link_nonce)
271
- redirect_to somewhere_else_path(session_token: link_nonce)
288
+ link_to "Link Name", somewhere_else_path(panda_token: link_nonce)
289
+ redirect_to somewhere_else_path(panda_token: link_nonce)
272
290
  ```
273
- You can also use the `redirect_with_session_to` helper, which will automatically add `organization_id:` and `session_token:` params:
291
+ You can also use the `redirect_with_session_to` helper, which will automatically the `panda_token:` param (and, to support legacy apps, an `organization_id:` param if needed):
274
292
  ```ruby
275
293
  redirect_with_session_to :somewhere_else_path, other_param: 1
276
294
  ```
277
- For each request (not each call), `link_nonce` generates a nonce and stores it in the Session. The generated value can be used
278
- for at-most-one future request. This means that browser back-forward navigation **will not work** (if it actually ever worked for
279
- iframe-based LTIs in the first place).
295
+
296
+ For each request (not each call), `link_nonce` Limited-Use Token.
297
+ The default `typ`e is `T-NONCE`, but this can be controlled by passing a `type:` param, or by setting `link_nonce_type` in the controller, eg:
298
+ ```ruby
299
+ link_nonce_type "T-IP"
300
+
301
+ def action
302
+ redirect_to somewhere_else_path(panda_token: link_nonce(type: "T-IP"))
303
+ end
304
+ ```
280
305
 
281
306
  ### Persisting the session
282
307
  The session is automatically saved using an `after_action` callback. This callback is poised to run last, but if that causes issues
@@ -484,6 +509,7 @@ For :fixed_ip and :expiring tokens you can override the default expiration perio
484
509
 
485
510
  See the following example of how to override the link_nonce_type and token expiration length.
486
511
 
512
+ ```ruby
487
513
  class ApplicationController < ActionController::Base
488
514
  link_nonce_type :fixed_ip
489
515
  def session_expiration_period_minutes
@@ -491,6 +517,7 @@ class ApplicationController < ActionController::Base
491
517
  end
492
518
  ...
493
519
  end
520
+ ```
494
521
 
495
522
  ### Previous Safari Instructions
496
523
  Safari is weird and you'll potentially run into issues getting `POST` requests to properly validate CSRF if you don't do the following:
@@ -16,7 +16,8 @@ module PandaPal
16
16
  current_session.data.merge!({
17
17
  lti_version: 'v1p0',
18
18
  lti_launch_placement: params[:launch_type],
19
- launch_params: params.to_unsafe_h,
19
+ launch_url_params: request.query_parameters.to_h,
20
+ launch_params: request.request_parameters.to_h,
20
21
  })
21
22
 
22
23
  redirect_with_session_to(:"#{LaunchUrlHelpers.launch_route(params[:launch_type])}_url", route_context: main_app)
@@ -24,7 +24,7 @@ module PandaPal
24
24
  current_session[:lti_oauth_nonce] = SecureRandom.uuid
25
25
  current_session[:canvas_environment] = params['canvas_environment']
26
26
  current_session[:canvas_region] = params['canvas_region']
27
- current_session.panda_pal_organization_id = -1
27
+ current_session.panda_pal_organization_id = 0
28
28
 
29
29
  @form_action = current_lti_platform.authentication_redirect_url
30
30
  @method = :post
@@ -49,6 +49,7 @@ module PandaPal
49
49
  current_session.data.merge!({
50
50
  lti_version: 'v1p3',
51
51
  lti_launch_placement: ltype,
52
+ launch_url_params: request.query_parameters.to_h,
52
53
  launch_params: @decoded_lti_jwt,
53
54
  })
54
55
 
@@ -297,6 +297,54 @@ module PandaPal
297
297
  _find_existing_installs(context)
298
298
  end
299
299
 
300
+ def canvas_lti_launch_url(placement_or_context, context: nil, host: nil, params: {}, lti_id: nil)
301
+ if placement_or_context.is_a?(String) && placement_or_context.include?('/')
302
+ context = placement_or_context
303
+ placement = nil
304
+ placement_or_context = nil
305
+ elsif placement_or_context.is_a?(Symbol) || placement_or_context.is_a?(String)
306
+ placement = placement_or_context.to_s
307
+ elsif !context
308
+ context = placement_or_context
309
+ placement_or_context = nil
310
+ else
311
+ raise ArgumentError, "context seems to have been passed twice"
312
+ end
313
+
314
+ if context.is_a?(String)
315
+ context_path = context
316
+ elsif context
317
+ context_name = context.class.name.downcase
318
+ placement ||= context_name + "_navigation"
319
+ context_path = "#{context_name}s/#{context.canvas_id}"
320
+ else
321
+ placement ||= "account_navigation"
322
+ context_path = "accounts/#{current_organization.canvas_account_id}"
323
+ end
324
+
325
+ if %w[global account course user].include?(placement)
326
+ placement = "#{placement}_navigation"
327
+ end
328
+
329
+ canvas_uri = URI.parse(self.canvas_url)
330
+ lti_id ||= self.settings.dig(:canvas, :external_tool_id)
331
+ canvas_uri.path = "/#{context_path}/external_tools/#{lti_id}"
332
+
333
+ canvas_params = {}
334
+ canvas_params[:launch_type] = placement if placement == "global_navigation"
335
+ if params.present?
336
+ host = "#{host.scheme}://#{host.host_with_port}" if host.is_a?(ActionDispatch::Request)
337
+ canvas_params[:launch_url] = PandaPal::Engine.routes.url_helpers.v1p3_resource_link_request_url(
338
+ host: host,
339
+ **params,
340
+ ).to_s
341
+ end
342
+
343
+ canvas_uri.query = URI.encode_www_form(canvas_params)
344
+
345
+ canvas_uri
346
+ end
347
+
300
348
  def canvas_url
301
349
  PandaPal::Platform.find_org_setting([
302
350
  "canvas.base_url",
@@ -3,10 +3,36 @@ module PandaPal
3
3
  belongs_to :panda_pal_organization, class_name: 'PandaPal::Organization', optional: true
4
4
 
5
5
  after_initialize do
6
- self.session_key ||= SecureRandom.urlsafe_base64(60)
6
+ self.session_secret ||= SecureRandom.urlsafe_base64(60)
7
7
  self.data ||= {}.with_indifferent_access
8
8
  end
9
9
 
10
+ delegate :dig, to: :data
11
+
12
+ def session_key
13
+ # <B64({ v: TOKEN FORMAT, o: ORG ID, s: SESSION ID, typ: "KEY" })>.<SESSION SECRET>
14
+ payload = {
15
+ v: 1,
16
+ o: panda_pal_organization_id,
17
+ s: id,
18
+ typ: "KEY",
19
+ }
20
+ base64_payload = Base64.urlsafe_encode64(payload.to_json)
21
+ "#{base64_payload}.#{session_secret}"
22
+ end
23
+
24
+ def signing_key
25
+ session_secret
26
+ end
27
+
28
+ def sign_value(data)
29
+ OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), signing_key, data)
30
+ end
31
+
32
+ def validate_signature(data, signature)
33
+ signature == sign_value(data)
34
+ end
35
+
10
36
  def [](key)
11
37
  if self.class.column_names.include?(key.to_s)
12
38
  super
@@ -200,7 +200,7 @@ end
200
200
 
201
201
  Apartment.configure do |config|
202
202
  config.excluded_models ||= []
203
- config.excluded_models |= ['PandaPal::Organization', 'PandaPal::Session']
203
+ config.excluded_models |= ['PandaPal::Organization']
204
204
 
205
205
  config.with_multi_server_setup = true unless Rails.env.test?
206
206
 
@@ -224,6 +224,11 @@ Rails.application.config.middleware.use Apartment::Elevators::Generic, lambda {
224
224
  PandaPal::Organization.find_by(id: match[1]).try(:tenant_name)
225
225
  elsif match = request.path.match(/\/(?:orgs?|organizations?|o)\/(\w+)/)
226
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)
227
232
  elsif request.path.start_with?('/rails/active_storage/blobs/')
228
233
  PandaPal::Organization.find_by(id: request.params['organization_id']).try(:tenant_name)
229
234
  end
@@ -0,0 +1,5 @@
1
+ class RenameSessionSessionKey < ActiveRecord::Migration[7.1]
2
+ def change
3
+ rename_column :panda_pal_sessions, :session_key, :session_secret
4
+ end
5
+ end
@@ -93,12 +93,21 @@ module PandaPal::Helpers
93
93
  decoded_jwt.verify!(current_lti_platform.public_jwks(force: true))
94
94
  end
95
95
 
96
+ raise JSON::JWT::VerificationFailed, 'State is invalid' unless current_panda_session.panda_pal_organization_id == 0
96
97
  raise JSON::JWT::VerificationFailed, 'State is invalid' unless current_panda_session[:lti_oauth_nonce] == decoded_jwt['nonce']
97
98
 
98
99
  jwt_verifier = PandaPal::LtiJwtValidator.new(decoded_jwt, client_id)
99
100
  raise JSON::JWT::VerificationFailed, jwt_verifier.errors unless jwt_verifier.valid?
100
101
 
101
- current_panda_session.update(panda_pal_organization: @organization)
102
+ # Migrate the session to the correct tenant
103
+ login_session = current_panda_session
104
+ login_session.delete
105
+
106
+ @organization.switch_tenant
107
+
108
+ @current_session = login_session.dup
109
+ @current_session.panda_pal_organization = @organization
110
+ @current_session.save!
102
111
 
103
112
  @decoded_lti_jwt = decoded_jwt
104
113
  rescue JSON::JWT::VerificationFailed => e
@@ -35,29 +35,71 @@ module PandaPal::Helpers
35
35
 
36
36
  def start_panda_session!
37
37
  raise "Session already started" if @current_session.present?
38
- @current_session = PandaPal::Session.new(panda_pal_organization_id: current_organization&.id)
38
+ @current_session = PandaPal::Session.new(panda_pal_organization_id: current_organization&.id).tap do |session|
39
+ session.save!
40
+ end
39
41
  end
40
42
 
41
43
  def current_panda_session
42
44
  return @current_session if defined?(@current_session)
43
45
 
44
- if params[:session_token]
45
- payload = JSON.parse(session_cryptor.decrypt_and_verify(params[:session_token])).with_indifferent_access
46
- matched_session = PandaPal::Session.find_by(session_key: payload[:session_key])
47
- if matched_session.present?
48
- if payload[:token_type] == 'nonce' && matched_session.data[:link_nonce] == payload[:nonce]
49
- @current_session = matched_session
50
- @current_session.data[:link_nonce] = nil
51
- elsif payload[:token_type] == 'fixed_ip' && matched_session.data[:remote_ip] == request.remote_ip &&
52
- DateTime.parse(matched_session.data[:last_ip_token_requested]) > session_expiration_period_minutes.minutes.ago
53
- @current_session = matched_session
54
- elsif payload[:token_type] == 'expiring' && DateTime.parse(matched_session.data[:last_token_requested]) > session_expiration_period_minutes.minutes.ago
55
- @current_session = matched_session
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" if session_id && @current_session.id != session_id
62
+ else
63
+ session_record = PandaPal::Session.find_by(id: session_id)
64
+ raise SessionNonceMismatch, "Session Not Found" unless session_record.present?
65
+
66
+ # Validate the header against the signature
67
+ raise SessionNonceMismatch, "Invalid Signature" unless session_record.validate_signature(header, sig)
68
+
69
+ # Check expiration
70
+ if (expr = decoded_header['exp']).present?
71
+ raise SessionNonceMismatch, "Expired" unless expr > DateTime.now.iso8601
72
+ end
73
+
74
+ if type == "T-URI"
75
+ # Signed URLs only support GET requests
76
+ raise SessionNonceMismatch, "Invalid Method" unless request.method == :get
77
+
78
+ # Verify the signature against the request URL
79
+ resigned = generate_signed_url(session_record.signing_key, request.url)
80
+ raise SessionNonceMismatch, "Invalid Signature" unless resigned == decoded_header['usig']
81
+
82
+ # TODO Support single-use tokens via Redis?
83
+
84
+ @current_session = session_record
85
+ elsif type == "T-NONCE"
86
+ raise SessionNonceMismatch, "Invalid Nonce" unless session_record.data[:link_nonce] == decoded_header['nnc']
87
+ @current_session = session_record
88
+ elsif type == "T-IP"
89
+ raise SessionNonceMismatch, "Invalid IP" unless decoded_header["ip"] == request.remote_ip
90
+ @current_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
+ @current_session = session_record
56
95
  end
57
96
  end
97
+
58
98
  raise SessionNonceMismatch, "Session Not Found" unless @current_session.present?
99
+
59
100
  elsif (session_key = params[:session_key] || session_key_header || flash[:session_key] || session[:session_key]).present?
60
- @current_session = PandaPal::Session.find_by(session_key: session_key)
101
+ # Legacy-format session_keys
102
+ @current_session = PandaPal::Session.find_by(session_secret: session_key)
61
103
  end
62
104
 
63
105
  @current_session
@@ -110,39 +152,76 @@ module PandaPal::Helpers
110
152
  end
111
153
 
112
154
  def session_url_for(*args, **kwargs)
113
- url_for(build_session_url_params(*args, **kwargs))
155
+ url_with_session(:url_for, *args, **kwargs)
114
156
  end
115
157
 
116
- def url_with_session(location, *args, route_context: self, **kwargs)
117
- route_context.send(location, *build_session_url_params(*args, **kwargs))
158
+ def url_with_session(location, *args, route_context: self, nonce_type: link_nonce_type, **kwargs)
159
+ # Stay compatible with older Ruby/Rails :/
160
+ if args[-1].is_a?(Hash)
161
+ args[-1] = args[-1].dup
162
+ else
163
+ args.push({})
164
+ end
165
+
166
+ args[-1].merge!(
167
+ panda_token: Rails.env.development? ? current_session.session_key : link_nonce(type: nonce_type),
168
+ )
169
+
170
+ args[-1].merge!(kwargs)
171
+
172
+ begin
173
+ route_context.send(location, *args)
174
+ rescue ActionController::UrlGenerationError => ex
175
+ args[-1].merge!(organization_id: current_organization.id)
176
+ route_context.send(location, *args)
177
+ end
118
178
  end
119
179
 
120
- def link_nonce(type: link_nonce_type)
180
+ def link_nonce(url = nil, type: link_nonce_type)
121
181
  type = instance_exec(&type) if type.is_a?(Proc)
122
- type = type.to_s
123
-
124
- @cached_link_nonces ||= {}
125
- @cached_link_nonces[type] ||= begin
126
- payload = {
127
- token_type: type,
128
- session_key: current_session.session_key,
129
- organization_id: current_organization.id,
130
- }
131
-
132
- if type == 'nonce'
133
- current_session[:link_nonce] = SecureRandom.hex
134
- payload.merge!(nonce: current_session[:link_nonce])
135
- elsif type == 'fixed_ip'
136
- current_session[:remote_ip] ||= request.remote_ip
137
- current_session[:last_ip_token_requested] = DateTime.now.iso8601
138
- elsif type == 'expiring'
139
- current_session[:last_token_requested] = DateTime.now.iso8601
140
- else
141
- raise StandardError, "Unsupported link_nonce_type: '#{type}'"
182
+ type = type.to_s.downcase
183
+ type = type[2..] if type.start_with?('t-')
184
+
185
+ if type == 'url'
186
+ raise StandardError, "URL is required" unless url.present?
187
+ payload.merge!(
188
+ typ: 'T-URI',
189
+ usig: generate_signed_url(current_session.signing_key, url.to_s),
190
+ )
191
+ else
192
+ @cached_link_nonces ||= {}
193
+ @cached_link_nonces[type] ||= begin
194
+ payload = {
195
+ v: 1,
196
+ o: current_organization.id,
197
+ s: current_session.id,
198
+ }
199
+
200
+ if type == 'nonce'
201
+ current_session[:link_nonce] = SecureRandom.hex
202
+ payload.merge!(
203
+ typ: 'T-NONCE',
204
+ nnc: current_session[:link_nonce],
205
+ )
206
+ elsif type == 'ip' || type == 'fixed_ip'
207
+ payload.merge!(
208
+ typ: 'T-IP',
209
+ ip: request.remote_ip,
210
+ exp: session_expiration_period_minutes.minutes.from_now.iso8601,
211
+ )
212
+ elsif type == "exp" || type == 'expiring'
213
+ payload.merge!(
214
+ typ: 'T-EXP',
215
+ exp: session_expiration_period_minutes.minutes.from_now.iso8601,
216
+ )
217
+ else
218
+ raise StandardError, "Unsupported link_nonce_type: '#{type}'"
219
+ end
142
220
  end
143
-
144
- session_cryptor.encrypt_and_sign(payload.to_json)
145
221
  end
222
+
223
+ b64 = Base64.urlsafe_encode64(payload.to_json)
224
+ "#{b64}.#{current_session.sign_value(b64)}"
146
225
  end
147
226
 
148
227
  def link_nonce_type
@@ -153,6 +232,22 @@ module PandaPal::Helpers
153
232
  15
154
233
  end
155
234
 
235
+ def self.extract_panda_token(request, params = request.params)
236
+ headers = request.respond_to?(:headers) ? request.headers : request.env
237
+ auth_header = headers['HTTP_X_PANDA_TOKEN'] || headers['X-Panda-Token']
238
+ auth_header ||= headers['HTTP_AUTHORIZATION'] || headers['Authorization']
239
+
240
+ if auth_header.present?
241
+ match = auth_header.match(/Bearer (.+)/)
242
+ match ||= auth_header.match(/token=(.+)/) # Legacy Support
243
+ token = match[1] if match && match[1].include?('.')
244
+ end
245
+ token ||= params["panda_token"]
246
+ token ||= params["session_token"] if params["session_token"]&.include?('.') # Legacy Support
247
+ token ||= params["session_key"] if params["session_key"]&.include?('.') # Legacy Support
248
+ token.presence
249
+ end
250
+
156
251
  private
157
252
 
158
253
  def session_cryptor
@@ -166,34 +261,24 @@ module PandaPal::Helpers
166
261
  end
167
262
  end
168
263
 
169
- def build_session_url_params(*args, nonce_type: link_nonce_type, **kwargs)
170
- if args[-1].is_a?(Hash)
171
- args[-1] = args[-1].dup
172
- else
173
- args.push({})
174
- end
175
-
176
- if Rails.env.development?
177
- args[-1].merge!(
178
- session_key: current_session.session_key,
179
- organization_id: current_organization.id,
180
- )
181
- else
182
- args[-1].merge!(
183
- session_token: link_nonce(type: nonce_type),
184
- organization_id: current_organization.id,
185
- )
186
- end
187
-
188
- args[-1].merge!(kwargs)
189
- args
190
- end
191
-
192
264
  def auto_save_session
193
265
  yield if block_given?
194
266
  save_session if @current_session && session_changed?
195
267
  end
196
268
 
269
+ def generate_signed_url(key, uri)
270
+ uri = URI.parse(uri) if uri.is_a?(String)
271
+ signable = "#{uri.path}?#{uri.query}"
272
+ OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), key, signable)
273
+ end
274
+
275
+ def validate_url_signature(key, uri, signature)
276
+ uri = URI.parse(uri) if uri.is_a?(String)
277
+ signable = uri.path + '?' + uri.query
278
+ signable.gsub!(/[&?](session_key|session_token|panda_token)=.*?(&.*)?$/, "")
279
+ signature == OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), key, signable)
280
+ end
281
+
197
282
  def monkeypatch_flash
198
283
  if valid_session? && (value = current_session['flashes']).present?
199
284
  flashes = value["flashes"]
@@ -1,3 +1,3 @@
1
1
  module PandaPal
2
- VERSION = "5.13.4"
2
+ VERSION = "5.14.0.beta1"
3
3
  end
@@ -2,16 +2,15 @@
2
2
  # of editing this file, please use the migrations feature of Active Record to
3
3
  # incrementally modify your database, and then regenerate this schema definition.
4
4
  #
5
- # Note that this schema.rb definition is the authoritative source for your
6
- # database schema. If you need to create the application database on another
7
- # system, you should be using db:schema:load, not running all the migrations
8
- # from scratch. The latter is a flawed and unsustainable approach (the more migrations
9
- # you'll amass, the slower it'll run and the greater likelihood for issues).
5
+ # This file is the source Rails uses to define your schema when running `bin/rails
6
+ # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
7
+ # be faster and is potentially less error prone than running all of your
8
+ # migrations from scratch. Old migrations may fail to apply correctly if those
9
+ # migrations use external dependencies or application code.
10
10
  #
11
11
  # It's strongly recommended that you check this file into your version control system.
12
12
 
13
- ActiveRecord::Schema.define(version: 2022_07_21_095653) do
14
-
13
+ ActiveRecord::Schema[7.1].define(version: 2025_04_01_214421) do
15
14
  # These are extensions that must be enabled in order to support this database
16
15
  enable_extension "plpgsql"
17
16
 
@@ -19,8 +18,8 @@ ActiveRecord::Schema.define(version: 2022_07_21_095653) do
19
18
  t.text "logic"
20
19
  t.string "expiration"
21
20
  t.integer "uses_remaining"
22
- t.datetime "created_at", null: false
23
- t.datetime "updated_at", null: false
21
+ t.datetime "created_at", precision: nil, null: false
22
+ t.datetime "updated_at", precision: nil, null: false
24
23
  end
25
24
 
26
25
  create_table "panda_pal_organizations", id: :serial, force: :cascade do |t|
@@ -28,8 +27,8 @@ ActiveRecord::Schema.define(version: 2022_07_21_095653) do
28
27
  t.string "key"
29
28
  t.string "secret"
30
29
  t.string "canvas_account_id"
31
- t.datetime "created_at", null: false
32
- t.datetime "updated_at", null: false
30
+ t.datetime "created_at", precision: nil, null: false
31
+ t.datetime "updated_at", precision: nil, null: false
33
32
  t.string "salesforce_id"
34
33
  t.text "encrypted_settings"
35
34
  t.string "encrypted_settings_iv"
@@ -38,13 +37,13 @@ ActiveRecord::Schema.define(version: 2022_07_21_095653) do
38
37
  end
39
38
 
40
39
  create_table "panda_pal_sessions", id: :serial, force: :cascade do |t|
41
- t.string "session_key"
40
+ t.string "session_secret"
42
41
  t.text "data"
43
- t.datetime "created_at", null: false
44
- t.datetime "updated_at", null: false
42
+ t.datetime "created_at", precision: nil, null: false
43
+ t.datetime "updated_at", precision: nil, null: false
45
44
  t.integer "panda_pal_organization_id"
46
45
  t.index ["panda_pal_organization_id"], name: "index_panda_pal_sessions_on_panda_pal_organization_id"
47
- t.index ["session_key"], name: "index_panda_pal_sessions_on_session_key", unique: true
46
+ t.index ["session_secret"], name: "index_panda_pal_sessions_on_session_secret", unique: true
48
47
  end
49
48
 
50
49
  end
@@ -1,6 +1,6 @@
1
1
  FactoryGirl.define do
2
2
  factory :panda_pal_session, class: 'PandaPal::Session' do
3
- session_key "MyString"
3
+ session_secret "MyString"
4
4
  organization nil
5
5
  data "MyText"
6
6
  end
@@ -2,8 +2,8 @@ require 'rails_helper'
2
2
 
3
3
  module PandaPal
4
4
  RSpec.describe Session, type: :model do
5
- it 'is initialized with a session_key' do
6
- expect(PandaPal::Session.new.session_key).to_not be_nil
5
+ it 'is initialized with a session_secret' do
6
+ expect(PandaPal::Session.new.session_secret).to_not be_nil
7
7
  end
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: panda_pal
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.13.4
4
+ version: 5.14.0.beta1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Instructure CustomDev
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-04-03 00:00:00.000000000 Z
11
+ date: 2025-04-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -334,6 +334,7 @@ files:
334
334
  - db/migrate/20171205183457_encrypt_organization_settings.rb
335
335
  - db/migrate/20171205194657_remove_old_organization_settings.rb
336
336
  - db/migrate/20220721095653_create_panda_pal_api_calls.rb
337
+ - db/migrate/20250401214421_rename_session_session_key.rb
337
338
  - lib/panda_pal.rb
338
339
  - lib/panda_pal/concerns/ability_helper.rb
339
340
  - lib/panda_pal/engine.rb