require 'browser' module PandaPal::Helpers::ControllerHelper extend ActiveSupport::Concern class SessionNotFound < StandardError; end included do helper_method :link_nonce, :current_session after_action :auto_save_session end def save_session current_session.try(:save) end def current_session return @current_session if @current_session.present? if params[:session_token] payload = JSON.parse(panda_pal_cryptor.decrypt_and_verify(params[:session_token])).with_indifferent_access matched_session = PandaPal::Session.find_by(session_key: payload[:session_key]) if matched_session.present? && matched_session.data[:link_nonce] == payload[:nonce] @current_session = matched_session @current_session.data[:link_nonce] = nil end elsif (session_key = params[:session_key] || session_key_header || flash[:session_key] || session[:session_key]).present? @current_session = PandaPal::Session.find_by(session_key: session_key) if session_key.present? else @current_session = PandaPal::Session.new(panda_pal_organization_id: current_organization.id) end raise SessionNotFound, "Session Not Found" unless @current_session.present? @current_session end def current_organization @organization ||= PandaPal::Organization.find_by!(key: organization_key) if organization_key @organization ||= PandaPal::Organization.find_by(id: organization_id) if organization_id @organization ||= PandaPal::Organization.find_by_name(Apartment::Tenant.current) end def current_lti_platform return @current_lti_platform if @current_lti_platform.present? # TODO: (Future) This could be expanded more to take better advantage of the LTI 1.3 Multi-Tenancy model. if (canvas_url = current_organization&.settings&.dig(:canvas, :base_url)).present? @current_lti_platform ||= PandaPal::Platform::Canvas.new(canvas_url) end @current_lti_platform ||= PandaPal::Platform::Canvas.new('http://localhost:3000') if Rails.env.development? @current_lti_platform ||= PandaPal::Platform::CANVAS @current_lti_platform end def current_session_data current_session.data end def lti_launch_params current_session_data[:launch_params] end def session_changed? current_session.changed? && current_session.changes[:data].present? end def validate_launch! safari_override if params[:id_token].present? validate_v1p3_launch elsif params[:oauth_consumer_key].present? validate_v1p0_launch end end def validate_v1p0_launch authorized = false if @organization = params['oauth_consumer_key'] && PandaPal::Organization.find_by_key(params['oauth_consumer_key']) sanitized_params = request.request_parameters # These params come over with a safari-workaround launch. The authenticator doesn't like them, so clean them out. safe_unexpected_params = ["full_win_launch_requested", "platform_redirect_url", "dummy_param"] safe_unexpected_params.each do |p| sanitized_params.delete(p) end authenticator = IMS::LTI::Services::MessageAuthenticator.new(request.original_url, sanitized_params, @organization.secret) authorized = authenticator.valid_signature? end if !authorized render plain: 'Invalid Credentials, please contact your Administrator.', :status => :unauthorized unless authorized end authorized end def validate_v1p3_launch decoded_jwt = JSON::JWT.decode(params.require(:id_token), :skip_verification) raise JSON::JWT::VerificationFailed, 'error decoding id_token' if decoded_jwt.blank? client_id = decoded_jwt['aud'] @organization = PandaPal::Organization.find_by!(key: 'PandaPal') # client_id) raise JSON::JWT::VerificationFailed, 'Unrecognized Organization' unless @organization.present? decoded_jwt.verify!(current_lti_platform.public_jwks) params[:session_key] = params[:state] raise JSON::JWT::VerificationFailed, 'State is invalid' unless current_session_data[:lti_oauth_nonce] == decoded_jwt['nonce'] jwt_verifier = PandaPal::LtiJwtValidator.new(decoded_jwt, client_id) raise JSON::JWT::VerificationFailed, jwt_verifier.errors unless jwt_verifier.valid? @decoded_lti_jwt = decoded_jwt rescue JSON::JWT::VerificationFailed => e payload = Array(e.message) render json: { message: [ { errors: payload }, { id_token: params.require(:id_token) }, ], }, status: :unauthorized false end def switch_tenant(organization = current_organization, &block) return unless organization raise 'This method should be called in an around_action callback' unless block_given? Apartment::Tenant.switch(organization.name) do yield end end def forbid_access_if_lacking_session render plain: 'You should do an LTI Tool Launch.', status: :unauthorized unless valid_session? safari_override end def verify_authenticity_token # No need to check CSRF when no cookies were sent. This fixes CSRF failures in Browsers # that restrict Cookie setting within an IFrame. return unless request.cookies.keys.length > 0 super end def valid_session? [ current_session.persisted?, current_organization, current_session.panda_pal_organization_id == current_organization.id, Apartment::Tenant.current == current_organization.name ].all? end def safari_override use_secure_headers_override(:safari_override) if browser.safari? end # Redirect with the session key intact. In production, # handle this by adding a one-time use encrypted token to the URL. # Keeping it in the URL in development means that it plays # nicely with webpack-dev-server live reloading (otherwise # you get an access error everytime it tries to live reload). def redirect_with_session_to(location, params = {}, route_context: self, **rest) params.merge!(rest) if Rails.env.development? redirect_to route_context.send(location, { session_key: current_session.session_key, organization_id: current_organization.id, }.merge(params)) else redirect_to route_context.send(location, { session_token: link_nonce, organization_id: current_organization.id, }.merge(params)) end end def link_nonce @link_nonce ||= begin current_session_data[:link_nonce] = SecureRandom.hex payload = { session_key: current_session.session_key, organization_id: current_organization.id, nonce: current_session_data[:link_nonce], } panda_pal_cryptor.encrypt_and_sign(payload.to_json) end end private def organization_key org_key ||= params[:oauth_consumer_key] org_key ||= "#{params[:client_id]}/#{params[:deployment_id]}" if params[:client_id].present? org_key ||= session[:organization_key] org_key end def organization_id params[:organization_id] end def session_key_header if match = request.headers['Authorization'].try(:match, /token=(.+)/) match[1] end end def panda_pal_cryptor @panda_pal_cryptor ||= ActiveSupport::MessageEncryptor.new(Rails.application.secret_key_base[0..31]) end def auto_save_session yield if block_given? save_session if @current_session && session_changed? end end