begin require 'bearcat' rescue LoadError end module PandaPal class Platform::Canvas < Platform TRUSTED_ISSUERS = [ "https://sso.canvaslms.com", "https://sso.beta.canvaslms.com", "https://sso.test.canvaslms.com", # Deprecated (but still secure): "https://canvas.instructure.com", "https://canvas.beta.instructure.com", "https://canvas.test.instructure.com", ] def initialize(options) @issuer = options[:iss] end def platform_uri @issuer end def jwks_url "#{lti_api_domain}/api/lti/security/jwks" end def authentication_redirect_url "#{lti_api_domain}/api/lti/authorize_redirect" end def grant_url "#{lti_api_domain}/login/oauth2/token" end def is_trusted_env? return true unless Rails.env.production? TRUSTED_ISSUERS.include?(platform_uri) end def serialize super.merge(iss: @issuer) end protected def lti_api_domain case @issuer when "https://canvas.instructure.com"; "https://sso.canvaslms.com" when "https://canvas.beta.instructure.com"; "https://sso.beta.canvaslms.com" when "https://canvas.test.instructure.com"; "https://sso.test.canvaslms.com" else @issuer end end module OrgExtension extend ActiveSupport::Concern included do define_model_callbacks :lti_install end def _parse_lti_context(context) context = context.to_s context = 'account/self' if context == 'root_account' cid, ctype = context.split(/[\.\/]/).reverse ctype ||= 'account' [ctype, cid] end def _install_lti(host: nil, context: :root_account, version: nil, exists: :error, dedicated_deployment: false) raise "Automatically installing this LTI requires Bearcat." unless defined?(Bearcat) version = version || PandaPal.lti_options[:lti_spec_version] || 'v1p3' version = version.to_s if (vs = version.split(".")).count > 1 version = "v#{vs[0]}p#{vs[0]}" end # TODO Ensure host is actually this LTI run_callbacks :lti_install do ctype, cid = _parse_lti_context(context) if version == 'v1p0' existing_installs = _find_existing_installs(context, exists: exists) do |lti| lti[:consumer_key] == self.key end conf = { name: PandaPal.lti_options[:title], description: PandaPal.lti_options[:description], consumer_key: self.key, shared_secret: self.secret, privacy_level: "public", config_type: 'by_url', config_url: PandaPal::LaunchUrlHelpers.resolve_route(:v1p0_config_url, host: host), } api_result = existing_installs[0] ? bearcat_client.send(:"edit_#{ctype}_external_tool", cid, existing_installs[0][:id], conf) : bearcat_client.send(:"create_#{ctype}_external_tool", cid, conf) @new_lti_installation = api_result elsif version == 'v1p3' ekey = _ensure_lti_v1p3_key(exists: exists, host: host) existing_installs = _find_existing_installs(context, exists: exists) do |lti| # TODO This may not be correct when actually inheriting LTI keys lti[:developer_key_id] == (ekey[:id] % 10_000_000_000_000) end scope_tool = existing_installs[0] # Don't need to do an update if it already exists - Settings are stored on the LTI Key instead of the installation unless scope_tool scope_tool = bearcat_client.send(:"create_#{ctype}_external_tool", cid, { client_id: ekey[:id], }) new_client_id = "#{ekey[:id]}" new_client_id += "/#{scope_tool[:deployment_id]}" if dedicated_deployment self.key = new_client_id self.secret = ekey[:api_key] save! if changed? end @new_lti_installation = scope_tool else raise "Unrecognized LTI Version #{version}" end end save! end def install_lti(*args, **kwargs) switch_tenant do _install_lti(*args, **kwargs) end end def _ensure_lti_v1p3_key(exists:, host:) current_client_id = self.key.split('/')[0] existing_keys = [] existing_keys += bearcat_client.get("api/v1/accounts/self/developer_keys?per_page=100").filter do |devkey| devkey[:id].to_s == current_client_id end existing_keys += bearcat_client.get("api/v1/accounts/self/developer_keys?per_page=100", inherited: true).filter do |devkey| devkey[:id].to_s == current_client_id end ekey = existing_keys[0] lti_json_url = PandaPal::LaunchUrlHelpers.resolve_route(:v1p3_config_url, host: host) lti_json = JSON.parse(HTTParty.get(lti_json_url, format: :plain).body) valid_redirect_uris = [ lti_json["target_link_uri"] ] prod_domain = PandaPal.lti_environments[:domain] if prod_domain.present? PandaPal.lti_environments.each do |env, domain| env = env.to_s next unless env.ends_with?("_domain") env = env.split('_')[0] valid_redirect_uris << lti_json["target_link_uri"].gsub(prod_domain, domain) end end valid_redirect_uris.uniq! if !ekey # Create New ekey = bearcat_client.post("api/lti/accounts/self/developer_keys/tool_configuration", { developer_key: { name: PandaPal.lti_options[:title], redirect_uris: valid_redirect_uris.join("\n"), }, tool_configuration: { settings: lti_json, }, }) elsif exists == :replace || exists == :update # Update Existing ekey = bearcat_client.put("api/lti/developer_keys/#{ekey[:id]}/tool_configuration", { developer_key: { redirect_uris: valid_redirect_uris.join("\n"), }, # tool_configuration: lti_json, tool_configuration: { settings: lti_json, }, }) end ekey = ekey[:developer_key] || ekey if ekey[:developer_key_account_binding][:workflow_state] == "off" bearcat_client.post("api/v1/accounts/self/developer_keys/#{ekey[:id]}/developer_key_account_bindings", { developer_key_account_binding: { workflow_state: "on", }, }) end ekey end def _find_existing_installs(context, exists: nil, &matcher) ctype, cid = _parse_lti_context(context) existing_installs = bearcat_client.send(:"#{ctype}_external_tools", cid).all_pages_each.filter do |cet| matcher.call(cet) end if exists.present? && existing_installs.present? case exists when :error raise "Tool with key #{self.key} already installed" when :duplicate [] when :replace existing_installs.each do |install| bearcat_client.send(:"delete_#{ctype}_external_tool", cid, install[:id]) end [] when :update existing_installs else raise "exists: #{exists} is not supported" end else existing_installs end end def reinstall_lti!(*args, **kwargs) install_lti(*args, exists: :replace, **kwargs) end def lti_api_configuration(host: nil) PandaPal::LaunchUrlHelpers.with_uri_host(host) do domain = PandaPal.lti_properties[:domain] || host.host launch_url = PandaPal.lti_options[:secure_launch_url] || "#{domain}#{PandaPal.lti_options[:secure_launch_path]}" || PandaPal.lti_options[:launch_url] || "#{domain}#{PandaPal.lti_options[:launch_path]}" || domain lti_json = { name: PandaPal.lti_options[:title], description: PandaPal.lti_options[:description], domain: host.host, url: launch_url, consumer_key: self.key, shared_secret: self.secret, privacy_level: "public", custom_fields: {}, environments: PandaPal.lti_environments, } lti_json = lti_json.with_indifferent_access lti_json.merge!(PandaPal.lti_properties) (PandaPal.lti_options[:custom_fields] || []).each do |k, v| lti_json[:custom_fields][k] = v end PandaPal.lti_paths.each do |k, options| options = PandaPal::LaunchUrlHelpers.normalize_lti_launch_desc(options) options[:url] = PandaPal::LaunchUrlHelpers.absolute_launch_url( k.to_sym, host: host, launch_handler: :v1p0_launch_path, default_auto_launch: false ).to_s lti_json[k] = options end lti_json end end def lti_installations(context= :root_account) _find_existing_installs(context) end def canvas_url PandaPal::Platform.find_org_setting([ "canvas.base_url", "canvas_url", "canvas_base_url", "canvas.url", "base_url", ], self) || (Rails.env.development? && 'http://localhost:3000') || 'https://canvas.instructure.com' end def canvas_api_token PandaPal::Platform.find_org_setting([ "canvas.api_token", "canvas.api_key", "canvas.token", "canvas_api_token", "canvas_token", "api_token", ], self) end def root_account_info Rails.cache.fetch("panda_pal/org:#{name}/root_account_info", expires_in: 24.hours) do response = bearcat_client.account("self") response = bearcat_client.account(response[:root_account_id]) if response[:root_account_id].present? response end end if defined?(Bearcat) def bearcat_client # Less than ideal, but `canvas_sync_client` has been the long-adopted tradition so we check for it so that we can continue to drop-in new versions of PandaPal return canvas_sync_client if defined?(canvas_sync_client) Bearcat::Client.new( prefix: canvas_url, token: canvas_api_token, ) end end end end end