panda_pal 5.2.4 → 5.3.5
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 +23 -1
- data/app/controllers/panda_pal/lti_v1_p3_controller.rb +6 -2
- data/app/lib/panda_pal/misc_helper.rb +2 -2
- data/config/initializers/apartment.rb +59 -11
- data/lib/panda_pal/helpers/controller_helper.rb +101 -188
- data/lib/panda_pal/helpers/session_replacement.rb +192 -0
- data/lib/panda_pal/version.rb +1 -1
- metadata +7 -6
    
        checksums.yaml
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            ---
         | 
| 2 2 | 
             
            SHA256:
         | 
| 3 | 
            -
              metadata.gz:  | 
| 4 | 
            -
              data.tar.gz:  | 
| 3 | 
            +
              metadata.gz: 751203d046fbd547e8f194feabb335a81a08be96f6a557712f1673d139576a29
         | 
| 4 | 
            +
              data.tar.gz: b06b642432ce0d1e4a42a4bc93520b3782c6d8cce801e91508b296be60501482
         | 
| 5 5 | 
             
            SHA512:
         | 
| 6 | 
            -
              metadata.gz:  | 
| 7 | 
            -
              data.tar.gz:  | 
| 6 | 
            +
              metadata.gz: cc338a137cec8f7abb900b801ace89f1588b265d534babf98f6bb940307289b8a3175a9ce0d5d70a5ddeb520f3525d13e1ba0f7e52df34e75c6fda40ca03a3de
         | 
| 7 | 
            +
              data.tar.gz: 717e7355ae2cfb990f40c589df6c29c138f55d660fa0095367a275e000a7041e98e99ec631f6dd1bf9e9f3068c7a01006d4a3580ac017cb358a18b9c9711a87f
         | 
    
        data/README.md
    CHANGED
    
    | @@ -369,8 +369,30 @@ You will want to watch out for a few scenarios: | |
| 369 369 | 
             
            3) If you use `link_to` and navigate in your LTI (apps that are not single page)
         | 
| 370 370 | 
             
               make sure you include the `link_nonce` like so: 
         | 
| 371 371 | 
             
                ```ruby
         | 
| 372 | 
            -
                  link_to "Link Name", somewhere_else_path(session_token: link_nonce)
         | 
| 372 | 
            +
                  link_to "Link Name", somewhere_else_path(arg, session_token: link_nonce)
         | 
| 373 373 | 
             
                ```
         | 
| 374 | 
            +
                NB: As of PandaPal 5.2.6, you can instead use
         | 
| 375 | 
            +
                ```ruby
         | 
| 376 | 
            +
                  link_to "Name", url_with_session(:somewhere_else_path, arg, kwarg: 1)
         | 
| 377 | 
            +
                ```
         | 
| 378 | 
            +
             | 
| 379 | 
            +
            Persistent sessions have session_tokens as a way to safely communicate a session key in a way that is hopefully not too persistent in case it is logged somewhere.
         | 
| 380 | 
            +
            Options for communicating session_token -
         | 
| 381 | 
            +
            :nonce (default) - each nonce is good for exactly one communication with the backend server.  Once the nonce is used, it is no longer valid.
         | 
| 382 | 
            +
            :fixed_ip - each session_token is good until it expires.  It must be used from the same ip the LTI launched from.
         | 
| 383 | 
            +
            :expiring - this is the least secure.  Each token is good until it expires.
         | 
| 384 | 
            +
             | 
| 385 | 
            +
            For :fixed_ip and :expiring tokens you can override the default expiration period of 15 minutes.
         | 
| 386 | 
            +
             | 
| 387 | 
            +
            See the following example of how to override the link_nonce_type and token expiration length.
         | 
| 388 | 
            +
             | 
| 389 | 
            +
            class ApplicationController < ActionController::Base
         | 
| 390 | 
            +
              link_nonce_type :fixed_ip
         | 
| 391 | 
            +
              def session_expiration_period_minutes
         | 
| 392 | 
            +
                120
         | 
| 393 | 
            +
              end
         | 
| 394 | 
            +
            ...
         | 
| 395 | 
            +
            end
         | 
| 374 396 |  | 
| 375 397 | 
             
            ### Previous Safari Instructions
         | 
| 376 398 | 
             
            Safari is weird and you'll potentially run into issues getting `POST` requests to properly validate CSRF if you don't do the following:
         | 
| @@ -2,8 +2,6 @@ require_dependency "panda_pal/application_controller" | |
| 2 2 |  | 
| 3 3 | 
             
            module PandaPal
         | 
| 4 4 | 
             
              class LtiV1P3Controller < ApplicationController
         | 
| 5 | 
            -
                skip_before_action :verify_authenticity_token
         | 
| 6 | 
            -
             | 
| 7 5 | 
             
                before_action :validate_launch!, only: [:resource_link_request]
         | 
| 8 6 | 
             
                around_action :switch_tenant, only: [:resource_link_request]
         | 
| 9 7 |  | 
| @@ -37,6 +35,12 @@ module PandaPal | |
| 37 35 |  | 
| 38 36 | 
             
                    redirect_with_session_to(:"#{LaunchUrlHelpers.launch_route(params[:launch_type])}_url", route_context: main_app)
         | 
| 39 37 | 
             
                  end
         | 
| 38 | 
            +
                  # render json: {
         | 
| 39 | 
            +
                  #   launch_type: params[:launch_type],
         | 
| 40 | 
            +
                  #   final_url: LaunchUrlHelpers.launch_url(params[:launch_type]),
         | 
| 41 | 
            +
                  #   final_route: LaunchUrlHelpers.launch_route(params[:launch_type]),
         | 
| 42 | 
            +
                  #   decoded_jwt: @decoded_lti_jwt,
         | 
| 43 | 
            +
                  # }
         | 
| 40 44 | 
             
                end
         | 
| 41 45 |  | 
| 42 46 | 
             
                def tool_config
         | 
| @@ -4,9 +4,9 @@ module PandaPal | |
| 4 4 |  | 
| 5 5 | 
             
                def self.to_boolean(v)
         | 
| 6 6 | 
             
                  if Rails.version < '5.0'
         | 
| 7 | 
            -
                    ActiveRecord::Type::Boolean.new.type_cast_from_user( | 
| 7 | 
            +
                    ActiveRecord::Type::Boolean.new.type_cast_from_user(v)
         | 
| 8 8 | 
             
                  else
         | 
| 9 | 
            -
                    ActiveRecord::Type::Boolean.new.deserialize( | 
| 9 | 
            +
                    ActiveRecord::Type::Boolean.new.deserialize(v)
         | 
| 10 10 | 
             
                  end
         | 
| 11 11 | 
             
                end
         | 
| 12 12 | 
             
              end
         | 
| @@ -1,7 +1,8 @@ | |
| 1 1 | 
             
            require 'apartment/elevators/generic'
         | 
| 2 2 |  | 
| 3 3 | 
             
            Apartment.configure do |config|
         | 
| 4 | 
            -
              config.excluded_models  | 
| 4 | 
            +
              config.excluded_models ||= []
         | 
| 5 | 
            +
              config.excluded_models |= ['PandaPal::Organization', 'PandaPal::Session']
         | 
| 5 6 |  | 
| 6 7 | 
             
              config.tenant_names = lambda {
         | 
| 7 8 | 
             
                PandaPal::Organization.pluck(:name)
         | 
| @@ -14,6 +15,21 @@ Rails.application.config.middleware.use Apartment::Elevators::Generic, lambda { | |
| 14 15 | 
             
              end
         | 
| 15 16 | 
             
            }
         | 
| 16 17 |  | 
| 18 | 
            +
            module PandaPal::Plugins::ApartmentCache
         | 
| 19 | 
            +
              private
         | 
| 20 | 
            +
             | 
| 21 | 
            +
              if Rails.version >= '5.0'
         | 
| 22 | 
            +
                def normalize_key(key, options)
         | 
| 23 | 
            +
                  "tenant:#{Apartment::Tenant.current}/#{super}"
         | 
| 24 | 
            +
                end
         | 
| 25 | 
            +
              else
         | 
| 26 | 
            +
                def namespaced_key(*args)
         | 
| 27 | 
            +
                  "tenant:#{Apartment::Tenant.current}/#{super}"
         | 
| 28 | 
            +
                end
         | 
| 29 | 
            +
              end
         | 
| 30 | 
            +
            end
         | 
| 31 | 
            +
            ActiveSupport::Cache::Store.send(:prepend, PandaPal::Plugins::ApartmentCache)
         | 
| 32 | 
            +
             | 
| 17 33 | 
             
            if defined?(ActionCable)
         | 
| 18 34 | 
             
              module ActionCable
         | 
| 19 35 | 
             
                module Channel
         | 
| @@ -38,7 +54,7 @@ if defined?(ActionCable) | |
| 38 54 | 
             
                end
         | 
| 39 55 | 
             
              end
         | 
| 40 56 |  | 
| 41 | 
            -
              module ActionCableApartment
         | 
| 57 | 
            +
              module PandaPal::Plugins::ActionCableApartment
         | 
| 42 58 | 
             
                module Connection
         | 
| 43 59 | 
             
                  def tenant=(name)
         | 
| 44 60 | 
             
                    @tenant = name
         | 
| @@ -56,18 +72,50 @@ if defined?(ActionCable) | |
| 56 72 | 
             
                end
         | 
| 57 73 | 
             
              end
         | 
| 58 74 |  | 
| 59 | 
            -
              ActionCable::Connection::Base.prepend(ActionCableApartment::Connection)
         | 
| 75 | 
            +
              ActionCable::Connection::Base.prepend(PandaPal::Plugins::ActionCableApartment::Connection)
         | 
| 60 76 | 
             
            end
         | 
| 61 77 |  | 
| 62 | 
            -
             | 
| 63 | 
            -
               | 
| 78 | 
            +
            if defined?(Delayed)
         | 
| 79 | 
            +
              module PandaPal::Plugins
         | 
| 80 | 
            +
                class ApartmentDelayedJobsPlugin < ::Delayed::Plugin
         | 
| 81 | 
            +
                  callbacks do |lifecycle|
         | 
| 82 | 
            +
                    lifecycle.around(:enqueue) do |job, *args, &block|
         | 
| 83 | 
            +
                      current_tenant = Apartment::Tenant.current
         | 
| 64 84 |  | 
| 65 | 
            -
             | 
| 66 | 
            -
             | 
| 67 | 
            -
             | 
| 85 | 
            +
                      #make sure enqueue on public tenant unless we are testing since delayed job is set to run immediately
         | 
| 86 | 
            +
                      Apartment::Tenant.switch!('public') unless Rails.env.test?
         | 
| 87 | 
            +
                      job.tenant = current_tenant
         | 
| 88 | 
            +
                      begin
         | 
| 89 | 
            +
                        block.call(job, *args)
         | 
| 90 | 
            +
                      rescue Exception => e
         | 
| 91 | 
            +
                        Rails.logger.error("Error enqueing job #{job.to_s} - #{e.backtrace}")
         | 
| 92 | 
            +
                      ensure
         | 
| 93 | 
            +
                        #switch back to prev tenant
         | 
| 94 | 
            +
                        Apartment::Tenant.switch!(current_tenant)
         | 
| 95 | 
            +
                      end
         | 
| 96 | 
            +
                    end
         | 
| 97 | 
            +
             | 
| 98 | 
            +
                    lifecycle.before(:perform) do |worker, *args, &block|
         | 
| 99 | 
            +
                      tenant = args.first.tenant
         | 
| 100 | 
            +
                      Apartment::Tenant.switch!(tenant) if tenant.present?
         | 
| 101 | 
            +
                      Rails.logger.debug("Running job with tenant #{Apartment::Tenant.current}")
         | 
| 102 | 
            +
                    end
         | 
| 103 | 
            +
             | 
| 104 | 
            +
                    lifecycle.around(:invoke_job) do |job, *args, &block|
         | 
| 105 | 
            +
                      begin
         | 
| 106 | 
            +
                        block.call(job, *args)
         | 
| 107 | 
            +
                      ensure
         | 
| 108 | 
            +
                        Apartment::Tenant.switch!('public')
         | 
| 109 | 
            +
                        Rails.logger.debug("Resetting Tenant back to: #{Apartment::Tenant.current}")
         | 
| 110 | 
            +
                      end
         | 
| 111 | 
            +
                    end
         | 
| 68 112 |  | 
| 69 | 
            -
             | 
| 70 | 
            -
             | 
| 113 | 
            +
                    lifecycle.after(:failure) do |job, *args|
         | 
| 114 | 
            +
                      Rails.logger.error("Job failed on tenant: #{Apartment::Tenant.current}")
         | 
| 115 | 
            +
                    end
         | 
| 116 | 
            +
                  end
         | 
| 117 | 
            +
                end
         | 
| 71 118 | 
             
              end
         | 
| 119 | 
            +
             | 
| 120 | 
            +
              Delayed::Worker.plugins << PandaPal::Plugins::ApartmentDelayedJobsPlugin
         | 
| 72 121 | 
             
            end
         | 
| 73 | 
            -
            ActiveSupport::Cache::Store.send :prepend, ApartmentCache
         | 
| @@ -1,226 +1,139 @@ | |
| 1 1 | 
             
            require 'browser'
         | 
| 2 | 
            +
            require_relative 'session_replacement'
         | 
| 2 3 |  | 
| 3 | 
            -
            module PandaPal::Helpers | 
| 4 | 
            -
               | 
| 4 | 
            +
            module PandaPal::Helpers
         | 
| 5 | 
            +
              module ControllerHelper
         | 
| 6 | 
            +
                extend ActiveSupport::Concern
         | 
| 7 | 
            +
                include SessionReplacement
         | 
| 5 8 |  | 
| 6 | 
            -
             | 
| 7 | 
            -
             | 
| 8 | 
            -
             | 
| 9 | 
            -
             | 
| 10 | 
            -
             | 
| 11 | 
            -
                after_action :auto_save_session
         | 
| 12 | 
            -
              end
         | 
| 13 | 
            -
             | 
| 14 | 
            -
              def save_session
         | 
| 15 | 
            -
                current_session.try(:save)
         | 
| 16 | 
            -
              end
         | 
| 17 | 
            -
             | 
| 18 | 
            -
              def current_session
         | 
| 19 | 
            -
                return @current_session if @current_session.present?
         | 
| 20 | 
            -
             | 
| 21 | 
            -
                if params[:session_token]
         | 
| 22 | 
            -
                  payload = JSON.parse(panda_pal_cryptor.decrypt_and_verify(params[:session_token])).with_indifferent_access
         | 
| 23 | 
            -
                  matched_session = PandaPal::Session.find_by(session_key: payload[:session_key])
         | 
| 9 | 
            +
                def current_organization
         | 
| 10 | 
            +
                  @organization ||= PandaPal::Organization.find_by!(key: organization_key) if organization_key
         | 
| 11 | 
            +
                  @organization ||= PandaPal::Organization.find_by(id: organization_id) if organization_id
         | 
| 12 | 
            +
                  @organization ||= PandaPal::Organization.find_by_name(Apartment::Tenant.current)
         | 
| 13 | 
            +
                end
         | 
| 24 14 |  | 
| 25 | 
            -
             | 
| 26 | 
            -
             | 
| 27 | 
            -
             | 
| 15 | 
            +
                def current_lti_platform
         | 
| 16 | 
            +
                  return @current_lti_platform if @current_lti_platform.present?
         | 
| 17 | 
            +
                  # TODO: (Future) This could be expanded more to take better advantage of the LTI 1.3 Multi-Tenancy model.
         | 
| 18 | 
            +
                  if (canvas_url = current_organization&.settings&.dig(:canvas, :base_url)).present?
         | 
| 19 | 
            +
                    @current_lti_platform ||= PandaPal::Platform::Canvas.new(canvas_url)
         | 
| 28 20 | 
             
                  end
         | 
| 29 | 
            -
                   | 
| 30 | 
            -
             | 
| 31 | 
            -
                  @ | 
| 21 | 
            +
                  @current_lti_platform ||= PandaPal::Platform::Canvas.new('http://localhost:3000') if Rails.env.development?
         | 
| 22 | 
            +
                  @current_lti_platform ||= PandaPal::Platform::CANVAS
         | 
| 23 | 
            +
                  @current_lti_platform
         | 
| 32 24 | 
             
                end
         | 
| 33 25 |  | 
| 34 | 
            -
                 | 
| 35 | 
            -
             | 
| 36 | 
            -
                @current_session
         | 
| 37 | 
            -
              end
         | 
| 38 | 
            -
             | 
| 39 | 
            -
              def current_organization
         | 
| 40 | 
            -
                @organization ||= PandaPal::Organization.find_by!(key: organization_key) if organization_key
         | 
| 41 | 
            -
                @organization ||= PandaPal::Organization.find_by(id: organization_id) if organization_id
         | 
| 42 | 
            -
                @organization ||= PandaPal::Organization.find_by_name(Apartment::Tenant.current)
         | 
| 43 | 
            -
              end
         | 
| 44 | 
            -
             | 
| 45 | 
            -
              def current_lti_platform
         | 
| 46 | 
            -
                return @current_lti_platform if @current_lti_platform.present?
         | 
| 47 | 
            -
                # TODO: (Future) This could be expanded more to take better advantage of the LTI 1.3 Multi-Tenancy model.
         | 
| 48 | 
            -
                if (canvas_url = current_organization&.settings&.dig(:canvas, :base_url)).present?
         | 
| 49 | 
            -
                  @current_lti_platform ||= PandaPal::Platform::Canvas.new(canvas_url)
         | 
| 26 | 
            +
                def lti_launch_params
         | 
| 27 | 
            +
                  current_session_data[:launch_params]
         | 
| 50 28 | 
             
                end
         | 
| 51 | 
            -
                @current_lti_platform ||= PandaPal::Platform::Canvas.new('http://localhost:3000') if Rails.env.development?
         | 
| 52 | 
            -
                @current_lti_platform ||= PandaPal::Platform::CANVAS
         | 
| 53 | 
            -
                @current_lti_platform
         | 
| 54 | 
            -
              end
         | 
| 55 | 
            -
             | 
| 56 | 
            -
              def current_session_data
         | 
| 57 | 
            -
                current_session.data
         | 
| 58 | 
            -
              end
         | 
| 59 | 
            -
             | 
| 60 | 
            -
              def lti_launch_params
         | 
| 61 | 
            -
                current_session_data[:launch_params]
         | 
| 62 | 
            -
              end
         | 
| 63 | 
            -
             | 
| 64 | 
            -
              def session_changed?
         | 
| 65 | 
            -
                current_session.changed? && current_session.changes[:data].present?
         | 
| 66 | 
            -
              end
         | 
| 67 29 |  | 
| 68 | 
            -
             | 
| 69 | 
            -
             | 
| 30 | 
            +
                def validate_launch!
         | 
| 31 | 
            +
                  safari_override
         | 
| 70 32 |  | 
| 71 | 
            -
             | 
| 72 | 
            -
             | 
| 73 | 
            -
             | 
| 74 | 
            -
             | 
| 75 | 
            -
                end
         | 
| 76 | 
            -
              end
         | 
| 77 | 
            -
             | 
| 78 | 
            -
              def validate_v1p0_launch
         | 
| 79 | 
            -
                authorized = false
         | 
| 80 | 
            -
                if @organization = params['oauth_consumer_key'] && PandaPal::Organization.find_by_key(params['oauth_consumer_key'])
         | 
| 81 | 
            -
                  sanitized_params = request.request_parameters
         | 
| 82 | 
            -
                  # These params come over with a safari-workaround launch.  The authenticator doesn't like them, so clean them out.
         | 
| 83 | 
            -
                  safe_unexpected_params = ["full_win_launch_requested", "platform_redirect_url", "dummy_param"]
         | 
| 84 | 
            -
                  safe_unexpected_params.each do |p|
         | 
| 85 | 
            -
                    sanitized_params.delete(p)
         | 
| 33 | 
            +
                  if params[:id_token].present?
         | 
| 34 | 
            +
                    validate_v1p3_launch
         | 
| 35 | 
            +
                  elsif params[:oauth_consumer_key].present?
         | 
| 36 | 
            +
                    validate_v1p0_launch
         | 
| 86 37 | 
             
                  end
         | 
| 87 | 
            -
                  authenticator = IMS::LTI::Services::MessageAuthenticator.new(request.original_url, sanitized_params, @organization.secret)
         | 
| 88 | 
            -
                  authorized = authenticator.valid_signature?
         | 
| 89 38 | 
             
                end
         | 
| 90 39 |  | 
| 91 | 
            -
                 | 
| 92 | 
            -
                   | 
| 93 | 
            -
             | 
| 94 | 
            -
             | 
| 95 | 
            -
             | 
| 96 | 
            -
             | 
| 40 | 
            +
                def validate_v1p0_launch
         | 
| 41 | 
            +
                  authorized = false
         | 
| 42 | 
            +
                  if @organization = params['oauth_consumer_key'] && PandaPal::Organization.find_by_key(params['oauth_consumer_key'])
         | 
| 43 | 
            +
                    sanitized_params = request.request_parameters
         | 
| 44 | 
            +
                    # These params come over with a safari-workaround launch.  The authenticator doesn't like them, so clean them out.
         | 
| 45 | 
            +
                    safe_unexpected_params = ["full_win_launch_requested", "platform_redirect_url", "dummy_param"]
         | 
| 46 | 
            +
                    safe_unexpected_params.each do |p|
         | 
| 47 | 
            +
                      sanitized_params.delete(p)
         | 
| 48 | 
            +
                    end
         | 
| 49 | 
            +
                    authenticator = IMS::LTI::Services::MessageAuthenticator.new(request.original_url, sanitized_params, @organization.secret)
         | 
| 50 | 
            +
                    authorized = authenticator.valid_signature?
         | 
| 51 | 
            +
                  end
         | 
| 97 52 |  | 
| 98 | 
            -
             | 
| 99 | 
            -
             | 
| 100 | 
            -
             | 
| 53 | 
            +
                  if !authorized
         | 
| 54 | 
            +
                    render plain: 'Invalid Credentials, please contact your Administrator.', :status => :unauthorized unless authorized
         | 
| 55 | 
            +
                  end
         | 
| 101 56 |  | 
| 102 | 
            -
             | 
| 103 | 
            -
                 | 
| 104 | 
            -
                raise JSON::JWT::VerificationFailed, 'Unrecognized Organization' unless @organization.present?
         | 
| 57 | 
            +
                  authorized
         | 
| 58 | 
            +
                end
         | 
| 105 59 |  | 
| 106 | 
            -
                 | 
| 60 | 
            +
                def validate_v1p3_launch
         | 
| 61 | 
            +
                  decoded_jwt = JSON::JWT.decode(params.require(:id_token), :skip_verification)
         | 
| 62 | 
            +
                  raise JSON::JWT::VerificationFailed, 'error decoding id_token' if decoded_jwt.blank?
         | 
| 107 63 |  | 
| 108 | 
            -
             | 
| 109 | 
            -
             | 
| 64 | 
            +
                  client_id = decoded_jwt['aud']
         | 
| 65 | 
            +
                  @organization = PandaPal::Organization.find_by!(key: client_id)
         | 
| 66 | 
            +
                  raise JSON::JWT::VerificationFailed, 'Unrecognized Organization' unless @organization.present?
         | 
| 110 67 |  | 
| 111 | 
            -
             | 
| 112 | 
            -
                raise JSON::JWT::VerificationFailed, jwt_verifier.errors unless jwt_verifier.valid?
         | 
| 68 | 
            +
                  decoded_jwt.verify!(current_lti_platform.public_jwks)
         | 
| 113 69 |  | 
| 114 | 
            -
             | 
| 115 | 
            -
             | 
| 116 | 
            -
                payload = Array(e.message)
         | 
| 70 | 
            +
                  params[:session_key] = params[:state]
         | 
| 71 | 
            +
                  raise JSON::JWT::VerificationFailed, 'State is invalid' unless current_session_data[:lti_oauth_nonce] == decoded_jwt['nonce']
         | 
| 117 72 |  | 
| 118 | 
            -
             | 
| 119 | 
            -
                   | 
| 120 | 
            -
                    { errors: payload },
         | 
| 121 | 
            -
                    { id_token: params.require(:id_token) },
         | 
| 122 | 
            -
                  ],
         | 
| 123 | 
            -
                }, status: :unauthorized
         | 
| 73 | 
            +
                  jwt_verifier = PandaPal::LtiJwtValidator.new(decoded_jwt, client_id)
         | 
| 74 | 
            +
                  raise JSON::JWT::VerificationFailed, jwt_verifier.errors unless jwt_verifier.valid?
         | 
| 124 75 |  | 
| 125 | 
            -
             | 
| 126 | 
            -
             | 
| 76 | 
            +
                  @decoded_lti_jwt = decoded_jwt
         | 
| 77 | 
            +
                rescue JSON::JWT::VerificationFailed => e
         | 
| 78 | 
            +
                  payload = Array(e.message)
         | 
| 127 79 |  | 
| 128 | 
            -
             | 
| 129 | 
            -
             | 
| 130 | 
            -
             | 
| 80 | 
            +
                  render json: {
         | 
| 81 | 
            +
                    message: [
         | 
| 82 | 
            +
                      { errors: payload },
         | 
| 83 | 
            +
                      { id_token: params.require(:id_token) },
         | 
| 84 | 
            +
                    ],
         | 
| 85 | 
            +
                  }, status: :unauthorized
         | 
| 131 86 |  | 
| 132 | 
            -
             | 
| 133 | 
            -
                  yield
         | 
| 87 | 
            +
                  false
         | 
| 134 88 | 
             
                end
         | 
| 135 | 
            -
              end
         | 
| 136 89 |  | 
| 137 | 
            -
             | 
| 138 | 
            -
             | 
| 139 | 
            -
             | 
| 140 | 
            -
              end
         | 
| 90 | 
            +
                def switch_tenant(organization = current_organization, &block)
         | 
| 91 | 
            +
                  return unless organization
         | 
| 92 | 
            +
                  raise 'This method should be called in an around_action callback' unless block_given?
         | 
| 141 93 |  | 
| 142 | 
            -
             | 
| 143 | 
            -
             | 
| 144 | 
            -
             | 
| 145 | 
            -
                return unless request.cookies.keys.length > 0
         | 
| 146 | 
            -
                super
         | 
| 147 | 
            -
              end
         | 
| 148 | 
            -
             | 
| 149 | 
            -
              def valid_session?
         | 
| 150 | 
            -
                [
         | 
| 151 | 
            -
                  current_session.persisted?,
         | 
| 152 | 
            -
                  current_organization,
         | 
| 153 | 
            -
                  current_session.panda_pal_organization_id == current_organization.id,
         | 
| 154 | 
            -
                  Apartment::Tenant.current == current_organization.name
         | 
| 155 | 
            -
                ].all?
         | 
| 156 | 
            -
              rescue SessionNonceMismatch
         | 
| 157 | 
            -
                false
         | 
| 158 | 
            -
              end
         | 
| 159 | 
            -
             | 
| 160 | 
            -
              def safari_override
         | 
| 161 | 
            -
                use_secure_headers_override(:safari_override) if browser.safari?
         | 
| 162 | 
            -
              end
         | 
| 163 | 
            -
             | 
| 164 | 
            -
              # Redirect with the session key intact. In production,
         | 
| 165 | 
            -
              # handle this by adding a one-time use encrypted token to the URL.
         | 
| 166 | 
            -
              # Keeping it in the URL in development means that it plays
         | 
| 167 | 
            -
              # nicely with webpack-dev-server live reloading (otherwise
         | 
| 168 | 
            -
              # you get an access error everytime it tries to live reload).
         | 
| 169 | 
            -
             | 
| 170 | 
            -
              def redirect_with_session_to(location, params = {}, route_context: self, **rest)
         | 
| 171 | 
            -
                params.merge!(rest)
         | 
| 172 | 
            -
                if Rails.env.development?
         | 
| 173 | 
            -
                  redirect_to route_context.send(location, {
         | 
| 174 | 
            -
                    session_key: current_session.session_key,
         | 
| 175 | 
            -
                    organization_id: current_organization.id,
         | 
| 176 | 
            -
                  }.merge(params))
         | 
| 177 | 
            -
                else
         | 
| 178 | 
            -
                  redirect_to route_context.send(location, {
         | 
| 179 | 
            -
                    session_token: link_nonce,
         | 
| 180 | 
            -
                    organization_id: current_organization.id,
         | 
| 181 | 
            -
                  }.merge(params))
         | 
| 94 | 
            +
                  Apartment::Tenant.switch(organization.name) do
         | 
| 95 | 
            +
                    yield
         | 
| 96 | 
            +
                  end
         | 
| 182 97 | 
             
                end
         | 
| 183 | 
            -
              end
         | 
| 184 | 
            -
             | 
| 185 | 
            -
              def link_nonce
         | 
| 186 | 
            -
                @link_nonce ||= begin
         | 
| 187 | 
            -
                  current_session_data[:link_nonce] = SecureRandom.hex
         | 
| 188 98 |  | 
| 189 | 
            -
             | 
| 190 | 
            -
             | 
| 191 | 
            -
             | 
| 192 | 
            -
             | 
| 193 | 
            -
                  }
         | 
| 99 | 
            +
                def forbid_access_if_lacking_session
         | 
| 100 | 
            +
                  super
         | 
| 101 | 
            +
                  safari_override
         | 
| 102 | 
            +
                end
         | 
| 194 103 |  | 
| 195 | 
            -
             | 
| 104 | 
            +
                def valid_session?
         | 
| 105 | 
            +
                  return false unless current_session(create_missing: false)&.persisted?
         | 
| 106 | 
            +
                  return false unless current_organization
         | 
| 107 | 
            +
                  return false unless current_session.panda_pal_organization_id == current_organization.id
         | 
| 108 | 
            +
                  return false unless Apartment::Tenant.current == current_organization.name
         | 
| 109 | 
            +
                  true
         | 
| 110 | 
            +
                rescue SessionNonceMismatch
         | 
| 111 | 
            +
                  false
         | 
| 196 112 | 
             
                end
         | 
| 197 | 
            -
              end
         | 
| 198 113 |  | 
| 199 | 
            -
             | 
| 114 | 
            +
                def safari_override
         | 
| 115 | 
            +
                  use_secure_headers_override(:safari_override) if browser.safari?
         | 
| 116 | 
            +
                end
         | 
| 200 117 |  | 
| 201 | 
            -
             | 
| 202 | 
            -
                org_key ||= params[:oauth_consumer_key]
         | 
| 203 | 
            -
                org_key ||= "#{params[:client_id]}/#{params[:deployment_id]}" if params[:client_id].present?
         | 
| 204 | 
            -
                org_key ||= session[:organization_key]
         | 
| 205 | 
            -
                org_key
         | 
| 206 | 
            -
              end
         | 
| 118 | 
            +
                private
         | 
| 207 119 |  | 
| 208 | 
            -
             | 
| 209 | 
            -
             | 
| 210 | 
            -
             | 
| 211 | 
            -
             | 
| 212 | 
            -
             | 
| 213 | 
            -
             | 
| 214 | 
            -
                  match[1]
         | 
| 120 | 
            +
                def find_or_create_session(key:)
         | 
| 121 | 
            +
                  if key == :create
         | 
| 122 | 
            +
                    PandaPal::Session.new(panda_pal_organization_id: current_organization.id)
         | 
| 123 | 
            +
                  else
         | 
| 124 | 
            +
                    PandaPal::Session.find_by(session_key: key)
         | 
| 125 | 
            +
                  end
         | 
| 215 126 | 
             
                end
         | 
| 216 | 
            -
              end
         | 
| 217 127 |  | 
| 218 | 
            -
             | 
| 219 | 
            -
             | 
| 220 | 
            -
             | 
| 128 | 
            +
                def organization_key
         | 
| 129 | 
            +
                  org_key ||= params[:oauth_consumer_key]
         | 
| 130 | 
            +
                  org_key ||= "#{params[:client_id]}/#{params[:deployment_id]}" if params[:client_id].present?
         | 
| 131 | 
            +
                  org_key ||= session[:organization_key]
         | 
| 132 | 
            +
                  org_key
         | 
| 133 | 
            +
                end
         | 
| 221 134 |  | 
| 222 | 
            -
             | 
| 223 | 
            -
             | 
| 224 | 
            -
                 | 
| 135 | 
            +
                def organization_id
         | 
| 136 | 
            +
                  params[:organization_id]
         | 
| 137 | 
            +
                end
         | 
| 225 138 | 
             
              end
         | 
| 226 139 | 
             
            end
         | 
| @@ -0,0 +1,192 @@ | |
| 1 | 
            +
            module PandaPal::Helpers
         | 
| 2 | 
            +
              class SessionNonceMismatch < StandardError; end
         | 
| 3 | 
            +
             | 
| 4 | 
            +
              module SessionReplacement
         | 
| 5 | 
            +
                extend ActiveSupport::Concern
         | 
| 6 | 
            +
             | 
| 7 | 
            +
                included do
         | 
| 8 | 
            +
                  helper_method :link_nonce, :current_session, :current_session_data
         | 
| 9 | 
            +
                  helper_method :link_with_session_to, :url_with_session, :session_url_for
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                  prepend_around_action :monkeypatch_flash
         | 
| 12 | 
            +
                  prepend_around_action :auto_save_session
         | 
| 13 | 
            +
                end
         | 
| 14 | 
            +
             | 
| 15 | 
            +
                class_methods do
         | 
| 16 | 
            +
                  def link_nonce_type(value = :not_given)
         | 
| 17 | 
            +
                    if value == :not_given
         | 
| 18 | 
            +
                      @link_nonce_type || superclass.try(:link_nonce_type) || :nonce
         | 
| 19 | 
            +
                    else
         | 
| 20 | 
            +
                      @link_nonce_type = value
         | 
| 21 | 
            +
                    end
         | 
| 22 | 
            +
                  end
         | 
| 23 | 
            +
                end
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                def save_session
         | 
| 26 | 
            +
                  current_session.try(:save)
         | 
| 27 | 
            +
                end
         | 
| 28 | 
            +
             | 
| 29 | 
            +
                def current_session(create_missing: true)
         | 
| 30 | 
            +
                  return @current_session if @current_session.present?
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                  if params[:session_token]
         | 
| 33 | 
            +
                    payload = JSON.parse(session_cryptor.decrypt_and_verify(params[:session_token])).with_indifferent_access
         | 
| 34 | 
            +
                    matched_session = find_or_create_session(key: payload[:session_key])
         | 
| 35 | 
            +
                    if matched_session.present?
         | 
| 36 | 
            +
                      if payload[:token_type] == 'nonce' && matched_session.data[:link_nonce] == payload[:nonce]
         | 
| 37 | 
            +
                        @current_session = matched_session
         | 
| 38 | 
            +
                        @current_session.data[:link_nonce] = nil
         | 
| 39 | 
            +
                      elsif payload[:token_type] == 'fixed_ip' && matched_session.data[:remote_ip] == request.remote_ip &&
         | 
| 40 | 
            +
                        DateTime.parse(matched_session.data[:last_ip_token_requested]) > session_expiration_period_minutes.minutes.ago
         | 
| 41 | 
            +
                        @current_session = matched_session
         | 
| 42 | 
            +
                      elsif payload[:token_type] == 'expiring' && DateTime.parse(matched_session.data[:last_token_requested]) > session_expiration_period_minutes.minutes.ago
         | 
| 43 | 
            +
                        @current_session = matched_session
         | 
| 44 | 
            +
                      end
         | 
| 45 | 
            +
                    end
         | 
| 46 | 
            +
                    raise SessionNonceMismatch, "Session Not Found" unless @current_session.present?
         | 
| 47 | 
            +
                  elsif (session_key = params[:session_key] || session_key_header || flash[:session_key] || session[:session_key]).present?
         | 
| 48 | 
            +
                    @current_session = find_or_create_session(key: session_key)
         | 
| 49 | 
            +
                  end
         | 
| 50 | 
            +
             | 
| 51 | 
            +
                  @current_session ||= find_or_create_session(key: :create) if create_missing
         | 
| 52 | 
            +
             | 
| 53 | 
            +
                  @current_session
         | 
| 54 | 
            +
                end
         | 
| 55 | 
            +
             | 
| 56 | 
            +
                def current_session_data
         | 
| 57 | 
            +
                  current_session.data
         | 
| 58 | 
            +
                end
         | 
| 59 | 
            +
             | 
| 60 | 
            +
                def session_changed?
         | 
| 61 | 
            +
                  current_session.changed? && current_session.changes[:data].present?
         | 
| 62 | 
            +
                end
         | 
| 63 | 
            +
             | 
| 64 | 
            +
                def forbid_access_if_lacking_session
         | 
| 65 | 
            +
                  render plain: 'You should do an LTI Tool Launch.', status: :unauthorized unless valid_session?
         | 
| 66 | 
            +
                end
         | 
| 67 | 
            +
             | 
| 68 | 
            +
                def verify_authenticity_token
         | 
| 69 | 
            +
                  # No need to check CSRF when no cookies were sent. This fixes CSRF failures in Browsers
         | 
| 70 | 
            +
                  # that restrict Cookie setting within an IFrame.
         | 
| 71 | 
            +
                  return unless request.cookies.keys.length > 0
         | 
| 72 | 
            +
                  super
         | 
| 73 | 
            +
                end
         | 
| 74 | 
            +
             | 
| 75 | 
            +
                # Redirect with the session key intact. In production,
         | 
| 76 | 
            +
                # handle this by adding a one-time use encrypted token to the URL.
         | 
| 77 | 
            +
                # Keeping it in the URL in development means that it plays
         | 
| 78 | 
            +
                # nicely with webpack-dev-server live reloading (otherwise
         | 
| 79 | 
            +
                # you get an access error everytime it tries to live reload).
         | 
| 80 | 
            +
             | 
| 81 | 
            +
                def redirect_with_session_to(*args)
         | 
| 82 | 
            +
                  redirect_to url_with_session(*args)
         | 
| 83 | 
            +
                end
         | 
| 84 | 
            +
             | 
| 85 | 
            +
                def link_with_session_to(*args)
         | 
| 86 | 
            +
                  helpers.link_to url_with_session(*args)
         | 
| 87 | 
            +
                end
         | 
| 88 | 
            +
             | 
| 89 | 
            +
                def session_url_for(*args)
         | 
| 90 | 
            +
                  url_for(build_session_url_params(*args))
         | 
| 91 | 
            +
                end
         | 
| 92 | 
            +
             | 
| 93 | 
            +
                def url_with_session(location, *args, route_context: self, **kwargs)
         | 
| 94 | 
            +
                  route_context.send(location, *build_session_url_params(*args, **kwargs))
         | 
| 95 | 
            +
                end
         | 
| 96 | 
            +
             | 
| 97 | 
            +
                def link_nonce(type: link_nonce_type)
         | 
| 98 | 
            +
                  type = instance_exec(&type) if type.is_a?(Proc)
         | 
| 99 | 
            +
                  type = type.to_s
         | 
| 100 | 
            +
             | 
| 101 | 
            +
                  @cached_link_nonces ||= {}
         | 
| 102 | 
            +
                  @cached_link_nonces[type] ||= begin
         | 
| 103 | 
            +
                    payload = {
         | 
| 104 | 
            +
                      token_type: type,
         | 
| 105 | 
            +
                      session_key: current_session.session_key,
         | 
| 106 | 
            +
                      organization_id: current_organization.id,
         | 
| 107 | 
            +
                    }
         | 
| 108 | 
            +
             | 
| 109 | 
            +
                    if type == 'nonce'
         | 
| 110 | 
            +
                      current_session_data[:link_nonce] = SecureRandom.hex
         | 
| 111 | 
            +
                      payload.merge!(nonce: current_session_data[:link_nonce])
         | 
| 112 | 
            +
                    elsif type == 'fixed_ip'
         | 
| 113 | 
            +
                      current_session_data[:remote_ip] ||= request.remote_ip
         | 
| 114 | 
            +
                      current_session_data[:last_ip_token_requested] = DateTime.now.iso8601
         | 
| 115 | 
            +
                    elsif type == 'expiring'
         | 
| 116 | 
            +
                      current_session_data[:last_token_requested] = DateTime.now.iso8601
         | 
| 117 | 
            +
                    else
         | 
| 118 | 
            +
                      raise StandardError, "Unsupported link_nonce_type: '#{type}'"
         | 
| 119 | 
            +
                    end
         | 
| 120 | 
            +
             | 
| 121 | 
            +
                    session_cryptor.encrypt_and_sign(payload.to_json)
         | 
| 122 | 
            +
                  end
         | 
| 123 | 
            +
                end
         | 
| 124 | 
            +
             | 
| 125 | 
            +
                def link_nonce_type
         | 
| 126 | 
            +
                  self.class.link_nonce_type
         | 
| 127 | 
            +
                end
         | 
| 128 | 
            +
             | 
| 129 | 
            +
                def session_expiration_period_minutes
         | 
| 130 | 
            +
                  15
         | 
| 131 | 
            +
                end
         | 
| 132 | 
            +
             | 
| 133 | 
            +
                private
         | 
| 134 | 
            +
             | 
| 135 | 
            +
                def session_cryptor
         | 
| 136 | 
            +
                  secret_key_base = Rails.application.try(:secret_key_base) || Rails.application.secrets.secret_key_base
         | 
| 137 | 
            +
                  @session_cryptor ||= ActiveSupport::MessageEncryptor.new(secret_key_base[0..31])
         | 
| 138 | 
            +
                end
         | 
| 139 | 
            +
             | 
| 140 | 
            +
                def session_key_header
         | 
| 141 | 
            +
                  if match = request.headers['Authorization'].try(:match, /token=(.+)/)
         | 
| 142 | 
            +
                    match[1]
         | 
| 143 | 
            +
                  end
         | 
| 144 | 
            +
                end
         | 
| 145 | 
            +
             | 
| 146 | 
            +
                def build_session_url_params(*args, nonce_type: link_nonce_type, **kwargs)
         | 
| 147 | 
            +
                  if args[-1].is_a?(Hash)
         | 
| 148 | 
            +
                    args[-1] = args[-1].dup
         | 
| 149 | 
            +
                  else
         | 
| 150 | 
            +
                    args.push({})
         | 
| 151 | 
            +
                  end
         | 
| 152 | 
            +
             | 
| 153 | 
            +
                  if Rails.env.development?
         | 
| 154 | 
            +
                    args[-1].merge!(
         | 
| 155 | 
            +
                      session_key: current_session.session_key,
         | 
| 156 | 
            +
                      organization_id: current_organization.id,
         | 
| 157 | 
            +
                    )
         | 
| 158 | 
            +
                  else
         | 
| 159 | 
            +
                    args[-1].merge!(
         | 
| 160 | 
            +
                      session_token: link_nonce(type: nonce_type),
         | 
| 161 | 
            +
                      organization_id: current_organization.id,
         | 
| 162 | 
            +
                    )
         | 
| 163 | 
            +
                  end
         | 
| 164 | 
            +
             | 
| 165 | 
            +
                  args[-1].merge!(kwargs)
         | 
| 166 | 
            +
                  args
         | 
| 167 | 
            +
                end
         | 
| 168 | 
            +
             | 
| 169 | 
            +
                def auto_save_session
         | 
| 170 | 
            +
                  yield if block_given?
         | 
| 171 | 
            +
                  save_session if @current_session && session_changed?
         | 
| 172 | 
            +
                end
         | 
| 173 | 
            +
             | 
| 174 | 
            +
                def monkeypatch_flash
         | 
| 175 | 
            +
                  if valid_session? && (value = current_session_data['flashes']).present?
         | 
| 176 | 
            +
                    flashes = value["flashes"]
         | 
| 177 | 
            +
                    if discard = value["discard"]
         | 
| 178 | 
            +
                      flashes.except!(*discard)
         | 
| 179 | 
            +
                    end
         | 
| 180 | 
            +
                    flash.replace(flashes)
         | 
| 181 | 
            +
                    flash.discard()
         | 
| 182 | 
            +
                  end
         | 
| 183 | 
            +
             | 
| 184 | 
            +
                  yield
         | 
| 185 | 
            +
             | 
| 186 | 
            +
                  if @current_session.present?
         | 
| 187 | 
            +
                    current_session_data['flashes'] = flash.to_session_value
         | 
| 188 | 
            +
                    flash.discard()
         | 
| 189 | 
            +
                  end
         | 
| 190 | 
            +
                end
         | 
| 191 | 
            +
              end
         | 
| 192 | 
            +
            end
         | 
    
        data/lib/panda_pal/version.rb
    CHANGED
    
    
    
        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.3.5
         | 
| 5 5 | 
             
            platform: ruby
         | 
| 6 6 | 
             
            authors:
         | 
| 7 7 | 
             
            - Instructure ProServe
         | 
| 8 | 
            -
            autorequire: | 
| 8 | 
            +
            autorequire:
         | 
| 9 9 | 
             
            bindir: bin
         | 
| 10 10 | 
             
            cert_chain: []
         | 
| 11 | 
            -
            date: 2020- | 
| 11 | 
            +
            date: 2020-10-06 00:00:00.000000000 Z
         | 
| 12 12 | 
             
            dependencies:
         | 
| 13 13 | 
             
            - !ruby/object:Gem::Dependency
         | 
| 14 14 | 
             
              name: rails
         | 
| @@ -184,7 +184,7 @@ dependencies: | |
| 184 184 | 
             
                - - ">="
         | 
| 185 185 | 
             
                  - !ruby/object:Gem::Version
         | 
| 186 186 | 
             
                    version: '0'
         | 
| 187 | 
            -
            description: | 
| 187 | 
            +
            description:
         | 
| 188 188 | 
             
            email:
         | 
| 189 189 | 
             
            - pseng@instructure.com
         | 
| 190 190 | 
             
            executables: []
         | 
| @@ -237,6 +237,7 @@ files: | |
| 237 237 | 
             
            - lib/panda_pal/helpers/controller_helper.rb
         | 
| 238 238 | 
             
            - lib/panda_pal/helpers/route_helper.rb
         | 
| 239 239 | 
             
            - lib/panda_pal/helpers/secure_headers.rb
         | 
| 240 | 
            +
            - lib/panda_pal/helpers/session_replacement.rb
         | 
| 240 241 | 
             
            - lib/panda_pal/plugins.rb
         | 
| 241 242 | 
             
            - lib/panda_pal/version.rb
         | 
| 242 243 | 
             
            - lib/tasks/panda_pal_tasks.rake
         | 
| @@ -291,7 +292,7 @@ homepage: http://instructure.com | |
| 291 292 | 
             
            licenses:
         | 
| 292 293 | 
             
            - MIT
         | 
| 293 294 | 
             
            metadata: {}
         | 
| 294 | 
            -
            post_install_message: | 
| 295 | 
            +
            post_install_message:
         | 
| 295 296 | 
             
            rdoc_options: []
         | 
| 296 297 | 
             
            require_paths:
         | 
| 297 298 | 
             
            - lib
         | 
| @@ -307,7 +308,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement | |
| 307 308 | 
             
                  version: '0'
         | 
| 308 309 | 
             
            requirements: []
         | 
| 309 310 | 
             
            rubygems_version: 3.1.2
         | 
| 310 | 
            -
            signing_key: | 
| 311 | 
            +
            signing_key:
         | 
| 311 312 | 
             
            specification_version: 4
         | 
| 312 313 | 
             
            summary: LTI mountable engine
         | 
| 313 314 | 
             
            test_files:
         |