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 +4 -4
- data/README.md +48 -21
- data/app/controllers/panda_pal/lti_v1_p0_controller.rb +2 -1
- data/app/controllers/panda_pal/lti_v1_p3_controller.rb +2 -1
- data/app/models/panda_pal/platform/canvas.rb +48 -0
- data/app/models/panda_pal/session.rb +27 -1
- data/config/initializers/apartment.rb +6 -1
- data/db/migrate/20250401214421_rename_session_session_key.rb +5 -0
- data/lib/panda_pal/helpers/controller_helper.rb +10 -1
- data/lib/panda_pal/helpers/session_replacement.rb +148 -63
- data/lib/panda_pal/version.rb +1 -1
- data/spec/dummy/db/schema.rb +14 -15
- data/spec/factories/panda_pal_sessions.rb +1 -1
- data/spec/models/panda_pal/session_spec.rb +2 -2
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7dca28a2b62bd99b38f8e03e56ff2ca800a9e4783e3758e3f111d724033ecac9
|
4
|
+
data.tar.gz: 50cca8089331c5e3ced69508bd36ce38d1950f36e24c32ddcfe8013c5d203b08
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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`.
|
243
|
-
Session data can be accessed and modified using `current_session
|
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
|
-
###
|
254
|
-
The `session_key`
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
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(
|
271
|
-
redirect_to somewhere_else_path(
|
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
|
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
|
-
|
278
|
-
|
279
|
-
|
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
|
-
|
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 =
|
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.
|
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'
|
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
|
@@ -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
|
-
|
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
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
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
|
-
|
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
|
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
|
-
|
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
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
payload
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
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"]
|
data/lib/panda_pal/version.rb
CHANGED
data/spec/dummy/db/schema.rb
CHANGED
@@ -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
|
-
#
|
6
|
-
#
|
7
|
-
#
|
8
|
-
# from scratch.
|
9
|
-
#
|
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:
|
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 "
|
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 ["
|
46
|
+
t.index ["session_secret"], name: "index_panda_pal_sessions_on_session_secret", unique: true
|
48
47
|
end
|
49
48
|
|
50
49
|
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
|
6
|
-
expect(PandaPal::Session.new.
|
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.
|
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-
|
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
|