panda_pal 5.7.0.beta2 → 5.8.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: de960e9dd44bfec67832afe2e1cde116ea8a9e608f9658dea7cced1d45b1d934
4
- data.tar.gz: 8795f3fed5583c49d3ff729ef18e1e4b4ab21214b4d6319fe13a28e90073e00f
3
+ metadata.gz: 35907d0726df7ab474a251eedff8ff28a5d96fbf4996566454c777e20443d90d
4
+ data.tar.gz: 4f883c646c445d4a0a8ad918c669308c17a2a8392818e4645a5d9efdce5ee0e2
5
5
  SHA512:
6
- metadata.gz: 65b30bd9f2bfca02fcf92b451f85fd98e701a5251e0952d4468b4b84178b2a7149705fd7dd20783f6d89c8f1b02e4630ced8d00a0ceb22928927e19aa405c72e
7
- data.tar.gz: 320abed36d66d03e24c7c1cb38f545d094779559107f10fa084d464989ebb473a07b497b75eeb9d84c3febc16bfadd88f0002ca298ab67456e4c657261fdc8d6
6
+ metadata.gz: 4f259a0c7c5928892748bd08f88b259adc5e7bb41f90f7f71e90a6c7c5e37e7a243ad426d34a4ad6b59857891e2430a7f7860cbd15938f11eaae0f74f6571247
7
+ data.tar.gz: 10a665a5f0d00604f623a863255435c4f85b850de0e175dc1ef29417a83a0263c41d3ff6a7016bda9e74b2abe558da18ebb9ee87b21503aeb8920fada15c1b91
@@ -11,6 +11,9 @@ module PandaPal
11
11
  before_action :validate_launch!, only: [:resource_link_request]
12
12
  around_action :switch_tenant, only: [:resource_link_request]
13
13
 
14
+ # Redirect to beta/test as necessary
15
+ before_action :forward_to_env_and_region, only: [:login]
16
+
14
17
  def login
15
18
  @current_lti_platform = PandaPal::Platform.resolve_platform(params)
16
19
 
@@ -35,16 +38,16 @@ module PandaPal
35
38
  end
36
39
 
37
40
  def resource_link_request
38
- # Redirect to correct region/env?
41
+ ltype = @decoded_lti_jwt['https://www.instructure.com/placement']
39
42
 
40
- if params[:launch_type]
43
+ if ltype
41
44
  current_session_data.merge!({
42
45
  lti_version: 'v1p3',
43
- lti_launch_placement: params[:launch_type],
46
+ lti_launch_placement: ltype,
44
47
  launch_params: @decoded_lti_jwt,
45
48
  })
46
49
 
47
- redirect_with_session_to(:"#{LaunchUrlHelpers.launch_route(params[:launch_type])}_url", route_context: main_app)
50
+ redirect_with_session_to(:"#{LaunchUrlHelpers.launch_route(ltype)}_url", route_context: main_app)
48
51
  end
49
52
  # render json: {
50
53
  # launch_type: params[:launch_type],
@@ -90,7 +93,6 @@ module PandaPal
90
93
  privacy_level: "public",
91
94
  settings: {
92
95
  placements: mapped_placements,
93
- environments: PandaPal.lti_environments,
94
96
  },
95
97
  }],
96
98
  custom_fields: PandaPal.lti_custom_params, # PandaPal.lti_options[:custom_fields],
@@ -105,6 +107,37 @@ module PandaPal
105
107
  }
106
108
  end
107
109
 
110
+ protected
111
+
112
+ def forward_to_env_and_region
113
+ login_redirects = params['lti_login_redirects'] || []
114
+ login_redirects << request.url
115
+
116
+ redir_url = environment_login_url
117
+
118
+ if redir_url.present? && !login_redirects.include?(redir_url)
119
+ @form_action = redir_url
120
+ @method = request.method.downcase
121
+ @form_data = (request.POST.presence || request.GET).merge({
122
+ lti_login_redirects: login_redirects,
123
+ })
124
+
125
+ render action: :login
126
+ end
127
+ end
128
+
129
+ def environment_login_url
130
+ if (canvas_env = params['canvas_environment']).present?
131
+ tdomain = PandaPal.lti_environments[:"#{canvas_env}_domain"]
132
+
133
+ if tdomain.present? && !request.url.include?(tdomain)
134
+ return v1p3_oidc_login_url.gsub(PandaPal.lti_environments[:domain], tdomain)
135
+ end
136
+ end
137
+
138
+ nil
139
+ end
140
+
108
141
  private
109
142
 
110
143
  def auth_redirect_query
@@ -58,6 +58,12 @@ module PandaPal
58
58
  after_create :create_schema
59
59
  after_commit :destroy_schema, on: :destroy
60
60
 
61
+ define_setting("lti", {
62
+ type: 'Hash',
63
+ required: false,
64
+ properties: {},
65
+ })
66
+
61
67
  if defined?(scheduled_task)
62
68
  scheduled_task '0 0 3 * * *', :clean_old_sessions do
63
69
  PandaPal::Session.where(panda_pal_organization: self).where('updated_at < ?', 1.week.ago).delete_all
@@ -152,11 +158,32 @@ module PandaPal
152
158
  #
153
159
  # The API is currently an Organization subclass (using `becomes()`), but such may change.
154
160
  def platform_api(platform_type = primary_platform)
161
+ return nil if platform_type.nil?
155
162
  scl = platform_type.organization_api
156
163
  return self if scl == self.class
157
164
  becomes(scl)
158
165
  end
159
166
 
167
+ define_setting("lti.trusted_platforms", {
168
+ type: 'Array',
169
+ required: false,
170
+ description: "Additional trusted JWT issuers when validating the LTI 1.3 OIDC handshake",
171
+ })
172
+
173
+ # Determines if the specified platform can create sessions in this organization
174
+ def trusted_platform?(platform)
175
+ # Trust Instructure-hosted Canvas
176
+ return true if platform.is_a?(PandaPal::Platform::Canvas) && platform.is_trusted_env?
177
+
178
+ # Trust issuers added to the Org settings
179
+ if (issuer = platform.platform_uri rescue nil).present?
180
+ trusted_issuers = settings.dig(:lti, :trusted_platforms) || []
181
+ return true if trusted_issuers.include?(issuer)
182
+ end
183
+
184
+ false
185
+ end
186
+
160
187
  protected
161
188
 
162
189
  # OrgExtension-Overridable method to allow multi-platform tool Orgs to implicitly include a Platform API
@@ -5,24 +5,57 @@ end
5
5
 
6
6
  module PandaPal
7
7
  class Platform::Canvas < Platform
8
- attr_accessor :organization
8
+ TRUSTED_ISSUERS = [
9
+ "https://sso.canvaslms.com",
10
+ "https://sso.beta.canvaslms.com",
11
+ "https://sso.test.canvaslms.com",
9
12
 
10
- KNOWN_ISSUERS = [
13
+ # Deprecated (but still secure):
11
14
  "https://canvas.instructure.com",
12
15
  "https://canvas.beta.instructure.com",
13
16
  "https://canvas.test.instructure.com",
14
17
  ]
15
18
 
19
+ def initialize(options)
20
+ @issuer = options[:iss]
21
+ end
22
+
23
+ def platform_uri
24
+ @issuer
25
+ end
26
+
16
27
  def jwks_url
17
- "#{@issuer}/api/lti/security/jwks"
28
+ "#{lti_api_domain}/api/lti/security/jwks"
18
29
  end
19
30
 
20
31
  def authentication_redirect_url
21
- "#{@issuer}/api/lti/authorize_redirect"
32
+ "#{lti_api_domain}/api/lti/authorize_redirect"
22
33
  end
23
34
 
24
35
  def grant_url
25
- "#{@issuer}/login/oauth2/token"
36
+ "#{lti_api_domain}/login/oauth2/token"
37
+ end
38
+
39
+ def is_trusted_env?
40
+ return true unless Rails.env.production?
41
+
42
+ TRUSTED_ISSUERS.include?(platform_uri)
43
+ end
44
+
45
+ def serialize
46
+ super.merge(iss: @issuer)
47
+ end
48
+
49
+ protected
50
+
51
+ def lti_api_domain
52
+ case @issuer
53
+ when "https://canvas.instructure.com"; "https://sso.canvaslms.com"
54
+ when "https://canvas.beta.instructure.com"; "https://sso.beta.canvaslms.com"
55
+ when "https://canvas.test.instructure.com"; "https://sso.test.canvaslms.com"
56
+ else
57
+ @issuer
58
+ end
26
59
  end
27
60
 
28
61
  module OrgExtension
@@ -44,13 +77,11 @@ module PandaPal
44
77
  def install_lti(host: nil, context: :root_account, version: 'v1p3', exists: :error, dedicated_deployment: false)
45
78
  raise "Automatically installing this LTI requires Bearcat." unless defined?(Bearcat)
46
79
 
47
- version = version.to_s
48
-
49
80
  run_callbacks :lti_install do
50
81
  ctype, cid = _parse_lti_context(context)
51
82
 
52
83
  if version == 'v1p0'
53
- existing_installs = _find_existing_installs(context, exists: exists) do |lti|
84
+ existing_installs = _find_existing_installs(exists: exists) do |lti|
54
85
  lti[:consumer_key] == self.key
55
86
  end
56
87
 
@@ -116,14 +147,28 @@ module PandaPal
116
147
  lti_json_url = PandaPal::LaunchUrlHelpers.resolve_route(:v1p3_config_url, host: host)
117
148
  lti_json = JSON.parse(HTTParty.get(lti_json_url, format: :plain).body)
118
149
 
150
+ valid_redirect_uris = [
151
+ lti_json["target_link_uri"]
152
+ ]
153
+
154
+ prod_domain = PandaPal.lti_environments[:domain]
155
+ if prod_domain.present?
156
+ PandaPal.lti_environments.each do |env, domain|
157
+ env = env.to_s
158
+ next unless env.endswith?("_domain")
159
+ env = env.split('_')[0]
160
+ valid_redirect_uris << lti_json["target_link_uri"].gsub(prod_domain, domain)
161
+ end
162
+ end
163
+
164
+ valid_redirect_uris.uniq!
165
+
119
166
  if !ekey
120
167
  # Create New
121
168
  ekey = bearcat_client.post("api/lti/accounts/self/developer_keys/tool_configuration", {
122
169
  developer_key: {
123
170
  name: PandaPal.lti_options[:title],
124
- redirect_uris: [
125
- lti_json["target_link_uri"],
126
- ].join("\n")
171
+ redirect_uris: valid_redirect_uris.join("\n"),
127
172
  },
128
173
  tool_configuration: {
129
174
  settings: lti_json,
@@ -5,10 +5,6 @@ module PandaPal
5
5
  class Platform
6
6
  require_relative "platform/canvas"
7
7
 
8
- def initialize(params)
9
- @issuer = params[:iss]
10
- end
11
-
12
8
  def public_jwks
13
9
  require "json/jwt"
14
10
 
@@ -27,14 +23,18 @@ module PandaPal
27
23
  nil
28
24
  end
29
25
 
26
+ def self.from_serialized(ser)
27
+ cls = ser[:platform_class].safe_constantize
28
+ cls.deserialize(ser)
29
+ end
30
+
30
31
  def self.deserialize(params)
31
- params[:platform_class].safe_constantize.new(params)
32
+ new(params)
32
33
  end
33
34
 
34
35
  def serialize
35
36
  {
36
37
  platform_class: self.class.to_s,
37
- iss: @issuer,
38
38
  }
39
39
  end
40
40
 
@@ -67,7 +67,7 @@ module PandaPal
67
67
  end
68
68
 
69
69
  # TODO Default logic?
70
- return Platform::Canvas if Platform::Canvas::KNOWN_ISSUERS.include?(params[:iss])
70
+ return Platform::Canvas if Platform::Canvas::TRUSTED_ISSUERS.include?(params[:iss])
71
71
 
72
72
  raise "Unknown platform '#{platform}'"
73
73
  end
@@ -96,12 +96,37 @@ module PandaPal
96
96
  end
97
97
 
98
98
  class Platform::Generic < Platform
99
- attr_reader :jwks_url, :authentication_redirect_url, :grant_url
99
+ def self.from_urls(base_url, jwks: nil, auth_redirect: nil, grant: nil)
100
+ new({
101
+ base_url: base_url,
102
+ jwks_url: jwks,
103
+ auth_redirect_url: auth_redirect,
104
+ grant_url: grant,
105
+ })
106
+ end
107
+
108
+ def initialize(options)
109
+ @options = options
110
+ end
111
+
112
+ def platform_uri
113
+ options[:issuer] || options[:base_url]
114
+ end
115
+
116
+ def jwks_url
117
+ URI.join(options[:base_url], options[:jwks_url] || "/api/lti/security/jwks").to_s
118
+ end
119
+
120
+ def authentication_redirect_url
121
+ URI.join(options[:base_url], options[:auth_redirect_url] || "/api/lti/authorize_redirect").to_s
122
+ end
123
+
124
+ def grant_url
125
+ URI.join(options[:base_url], options[:grant_url] || "/login/oauth2/token").to_s
126
+ end
100
127
 
101
- def init(base_url, jwks: "/api/lti/security/jwks", auth_redirect: "/api/lti/authorize_redirect", grant: "/login/oauth2/token")
102
- @jwks_url = URI.join(base_url, jwks).to_s
103
- @authentication_redirect_url = URI.join(base_url, auth_redirect).to_s
104
- @grant_url = URI.join(base_url, grant).to_s
128
+ def serialize
129
+ super.merge(options)
105
130
  end
106
131
  end
107
132
  end
@@ -9,7 +9,7 @@ module PandaPal
9
9
  def lti_platform
10
10
  return nil unless data[:lti_platform].present?
11
11
 
12
- @lti_platform ||= Platform.deserialize(data[:lti_platform])
12
+ @lti_platform ||= Platform.from_serialized(data[:lti_platform])
13
13
  end
14
14
 
15
15
  class DataSerializer
@@ -39,12 +39,16 @@ module PandaPal
39
39
 
40
40
  initializer 'Sidekiq Scheduler Hooks' do
41
41
  ActiveSupport.on_load(:active_record) do
42
- if defined?(Sidekiq) && Sidekiq.server? && PandaPal::Organization.respond_to?(:sync_schedules)
43
- PandaPal::Organization.sync_schedules
42
+ if defined?(Sidekiq) && Sidekiq.server? # && PandaPal::Organization.respond_to?(:sync_schedules)
44
43
 
45
- ActiveSupport::Reloader.to_prepare do
46
- PandaPal::Organization.sync_schedules
44
+ Rails.application.reloader.to_prepare do
45
+ PandaPal::Organization.sync_schedules if PandaPal::Organization.respond_to?(:sync_schedules)
47
46
  end
47
+ # PandaPal::Organization.sync_schedules
48
+
49
+ # ActiveSupport::Reloader.to_prepare do
50
+ # PandaPal::Organization.sync_schedules
51
+ # end
48
52
  end
49
53
  end
50
54
  end
@@ -74,21 +78,51 @@ module PandaPal
74
78
  def _panda_pal_console_app_name
75
79
  app_class = Rails.application.class
76
80
  app_name = app_class.respond_to?(:parent) ? app_class.parent : app_class.module_parent
81
+ app_name.to_s
82
+ end
83
+
84
+ def _panda_pal_short_console_app_name
85
+ _panda_pal_console_app_name[0...10]
86
+ end
87
+
88
+ def _panda_pal_console_env
89
+ if Rails.env.production?
90
+ env = ENV["SENTRY_CURRENT_ENV"].presence || "PROD"
91
+
92
+ if env.downcase.include?("prod")
93
+ Pry::Helpers::Text.red(env)
94
+ else
95
+ Pry::Helpers::Text.cyan(env)
96
+ end
97
+ elsif Rails.env.development?
98
+ Pry::Helpers::Text.cyan("dev")
99
+ elsif Rails.env.test?
100
+ Pry::Helpers::Text.cyan("test")
101
+ end
102
+ end
103
+
104
+ def _panda_pal_console_prefix
105
+ pfx = [
106
+ Pry::Helpers::Text.cyan(_panda_pal_short_console_app_name),
107
+ _panda_pal_console_env,
108
+ Pry::Helpers::Text.cyan(Apartment::Tenant.current),
109
+ ].compact.join('-')
110
+ pfx
77
111
  end
78
112
  end
79
113
 
80
114
  if defined? IRB
81
- module PandaPalIrbTimePrompt
115
+ module PandaPalIrbPrompt
82
116
  def prompt(prompt, ltype, indent, line_no)
83
117
  formatted = super(prompt, ltype, indent, line_no)
84
- app_bit = Pry::Helpers::Text.cyan("#{_panda_pal_console_app_name}-#{Apartment::Tenant.current}")
118
+ app_bit = _panda_pal_console_prefix
85
119
  "[#{app_bit}] #{formatted}"
86
120
  end
87
121
  end
88
122
 
89
123
  module ::IRB
90
124
  class Irb
91
- prepend PandaPalIrbTimePrompt
125
+ prepend PandaPalIrbPrompt
92
126
  end
93
127
  end
94
128
  end
@@ -97,18 +131,16 @@ module PandaPal
97
131
  default_prompt = Pry::Prompt[:default]
98
132
  env = Pry::Helpers::Text.red(Rails.env.upcase)
99
133
 
100
- app_name = _panda_pal_console_app_name
101
-
102
134
  Pry.config.prompt = Pry::Prompt.new(
103
135
  'custom',
104
136
  'my custom prompt',
105
137
  [
106
138
  ->(*args) {
107
- app_bit = Pry::Helpers::Text.cyan("#{app_name}-#{Apartment::Tenant.current}")
139
+ app_bit = _panda_pal_console_prefix
108
140
  "#{app_bit}#{default_prompt.wait_proc.call(*args)}"
109
141
  },
110
142
  ->(*args) {
111
- app_bit = Pry::Helpers::Text.cyan("#{app_name}-#{Apartment::Tenant.current}")
143
+ app_bit = _panda_pal_console_prefix
112
144
  "#{app_bit}#{default_prompt.incomplete_proc.call(*args)}"
113
145
  },
114
146
  ],
@@ -70,10 +70,11 @@ module PandaPal::Helpers
70
70
 
71
71
  @organization ||= PandaPal::Organization.find_by(key: client_id)
72
72
 
73
- raise JSON::JWT::VerificationFailed, 'Unrecognized Organization' unless @organization.present?
74
-
75
73
  params[:session_key] = params[:state]
76
74
 
75
+ raise JSON::JWT::VerificationFailed, 'Unrecognized Organization' unless @organization.present?
76
+ raise JSON::JWT::VerificationFailed, 'Organization does not trust platform' unless @organization.trusted_platform?(current_lti_platform)
77
+
77
78
  decoded_jwt.verify!(current_lti_platform.public_jwks)
78
79
 
79
80
  raise JSON::JWT::VerificationFailed, 'State is invalid' unless current_session_data[:lti_oauth_nonce] == decoded_jwt['nonce']
@@ -1,3 +1,3 @@
1
1
  module PandaPal
2
- VERSION = "5.7.0.beta2"
2
+ VERSION = "5.8.0"
3
3
  end