panda_pal 5.7.0.beta2 → 5.8.1

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: 732dbb8785b96326d0d10cd6e602cbecaffe5cb4c6ee8b1b6ce7722cb5611ea5
4
+ data.tar.gz: 8ed7ae8382b0a7cd6534f5bc75bf403d9666b7fdce00d6b6f80c79bc68c2721f
5
5
  SHA512:
6
- metadata.gz: 65b30bd9f2bfca02fcf92b451f85fd98e701a5251e0952d4468b4b84178b2a7149705fd7dd20783f6d89c8f1b02e4630ced8d00a0ceb22928927e19aa405c72e
7
- data.tar.gz: 320abed36d66d03e24c7c1cb38f545d094779559107f10fa084d464989ebb473a07b497b75eeb9d84c3febc16bfadd88f0002ca298ab67456e4c657261fdc8d6
6
+ metadata.gz: 9f55395d5b346c6f9a5c9d6f31716ed3b9ab2aa616cb46ff7e31b5b184703d8d087ce4b47b536d736a5e22e0da0d5ccf14bbc3e0d4cead243487ddb303a24d24
7
+ data.tar.gz: bd19857f79323ab6fa4f50c79e66bddf436a54344e02dab3794a95ee8cc2d54d0566ce764ddd42c88c838702279d4b4907a122a552ebf5f75f04e5438410d54f
data/README.md CHANGED
@@ -27,7 +27,7 @@ As of version 5.5.0, LTIs can be installed into Canvas via the console:
27
27
  org.install_lti(
28
28
  host: "https://your_lti.herokuapp.com",
29
29
  context: "account/self", # (Optional) Or "account/3", "course/1", etc
30
- exists: :error, # (Optional) Action to take if an LTI with the same Key already exists. Options are :error, :replace, :duplicate
30
+ exists: :error, # (Optional) Action to take if an LTI with the same Key already exists. Options are :error, :replace, :duplicate, :update
31
31
  version: "v1p3", # (Optional, default `v1p3`) LTI Version. Accepts `v1p0` or `v1p3`.
32
32
  dedicated_deployment: false, # (Optional) If true, the Organization will be updated to link to a single deployment rather then to the general LTI Key. (experimental, LTI 1.3 only)
33
33
  )
@@ -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
@@ -3,6 +3,8 @@ require 'ims/lti'
3
3
  require 'attr_encrypted'
4
4
  require 'secure_headers'
5
5
 
6
+ require_relative './helpers/console_helpers'
7
+
6
8
  module PandaPal
7
9
  class Engine < ::Rails::Engine
8
10
  config.autoload_once_paths += Dir["#{config.root}/lib/**/"]
@@ -39,11 +41,10 @@ module PandaPal
39
41
 
40
42
  initializer 'Sidekiq Scheduler Hooks' do
41
43
  ActiveSupport.on_load(:active_record) do
42
- if defined?(Sidekiq) && Sidekiq.server? && PandaPal::Organization.respond_to?(:sync_schedules)
43
- PandaPal::Organization.sync_schedules
44
+ if defined?(Sidekiq) && Sidekiq.server?
44
45
 
45
- ActiveSupport::Reloader.to_prepare do
46
- PandaPal::Organization.sync_schedules
46
+ Rails.application.reloader.to_prepare do
47
+ PandaPal::Organization.sync_schedules if PandaPal::Organization.respond_to?(:sync_schedules)
47
48
  end
48
49
  end
49
50
  end
@@ -74,21 +75,51 @@ module PandaPal
74
75
  def _panda_pal_console_app_name
75
76
  app_class = Rails.application.class
76
77
  app_name = app_class.respond_to?(:parent) ? app_class.parent : app_class.module_parent
78
+ app_name.to_s
79
+ end
80
+
81
+ def _panda_pal_short_console_app_name
82
+ _panda_pal_console_app_name[0...10]
83
+ end
84
+
85
+ def _panda_pal_console_env
86
+ if Rails.env.production?
87
+ env = ENV["SENTRY_CURRENT_ENV"].presence || "PROD"
88
+
89
+ if env.downcase.include?("prod")
90
+ PandaPal::ConsoleHelpers.red(env)
91
+ else
92
+ PandaPal::ConsoleHelpers.cyan(env)
93
+ end
94
+ elsif Rails.env.development?
95
+ PandaPal::ConsoleHelpers.cyan("dev")
96
+ elsif Rails.env.test?
97
+ PandaPal::ConsoleHelpers.cyan("test")
98
+ end
99
+ end
100
+
101
+ def _panda_pal_console_prefix
102
+ pfx = [
103
+ PandaPal::ConsoleHelpers.cyan(_panda_pal_short_console_app_name),
104
+ _panda_pal_console_env,
105
+ PandaPal::ConsoleHelpers.cyan(Apartment::Tenant.current),
106
+ ].compact.join('-')
107
+ pfx
77
108
  end
78
109
  end
79
110
 
80
111
  if defined? IRB
81
- module PandaPalIrbTimePrompt
112
+ module PandaPalIrbPrompt
82
113
  def prompt(prompt, ltype, indent, line_no)
83
114
  formatted = super(prompt, ltype, indent, line_no)
84
- app_bit = Pry::Helpers::Text.cyan("#{_panda_pal_console_app_name}-#{Apartment::Tenant.current}")
115
+ app_bit = _panda_pal_console_prefix
85
116
  "[#{app_bit}] #{formatted}"
86
117
  end
87
118
  end
88
119
 
89
120
  module ::IRB
90
121
  class Irb
91
- prepend PandaPalIrbTimePrompt
122
+ prepend PandaPalIrbPrompt
92
123
  end
93
124
  end
94
125
  end
@@ -97,18 +128,16 @@ module PandaPal
97
128
  default_prompt = Pry::Prompt[:default]
98
129
  env = Pry::Helpers::Text.red(Rails.env.upcase)
99
130
 
100
- app_name = _panda_pal_console_app_name
101
-
102
131
  Pry.config.prompt = Pry::Prompt.new(
103
132
  'custom',
104
133
  'my custom prompt',
105
134
  [
106
135
  ->(*args) {
107
- app_bit = Pry::Helpers::Text.cyan("#{app_name}-#{Apartment::Tenant.current}")
136
+ app_bit = _panda_pal_console_prefix
108
137
  "#{app_bit}#{default_prompt.wait_proc.call(*args)}"
109
138
  },
110
139
  ->(*args) {
111
- app_bit = Pry::Helpers::Text.cyan("#{app_name}-#{Apartment::Tenant.current}")
140
+ app_bit = _panda_pal_console_prefix
112
141
  "#{app_bit}#{default_prompt.incomplete_proc.call(*args)}"
113
142
  },
114
143
  ],
@@ -0,0 +1,27 @@
1
+ module PandaPal
2
+ module ConsoleHelpers
3
+ extend self
4
+
5
+ COLORS = {
6
+ "black" => 0,
7
+ "red" => 1,
8
+ "green" => 2,
9
+ "yellow" => 3,
10
+ "blue" => 4,
11
+ "purple" => 5,
12
+ "magenta" => 5,
13
+ "cyan" => 6,
14
+ "white" => 7
15
+ }.freeze
16
+
17
+ COLORS.each_pair do |color, value|
18
+ define_method color do |text|
19
+ "\033[0;#{30 + value}m#{text}\033[0m"
20
+ end
21
+
22
+ define_method "bright_#{color}" do |text|
23
+ "\033[1;#{30 + value}m#{text}\033[0m"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -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.1"
3
3
  end