diff --git a/app/controllers/concerns/dynamic_registration_service.rb b/app/controllers/concerns/dynamic_registration_service.rb
new file mode 100644
index 00000000..7e33b30a
--- /dev/null
+++ b/app/controllers/concerns/dynamic_registration_service.rb
@@ -0,0 +1,162 @@
+# frozen_string_literal: true
+
+# BigBlueButton open source conferencing system - http://www.bigbluebutton.org/.
+
+# Copyright (c) 2018 BigBlueButton Inc. and by respective authors (see below).
+
+# This program is free software; you can redistribute it and/or modify it under the
+# terms of the GNU Lesser General Public License as published by the Free Software
+# Foundation; either version 3.0 of the License, or (at your option) any later
+# version.
+
+# BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+# PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
+
+# You should have received a copy of the GNU Lesser General Public License along
+# with BigBlueButton; if not, see .
+
+module DynamicRegistrationService
+ include ActiveSupport::Concern
+
+ def client_registration_request_header(token)
+ {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ 'Authorization': "Bearer #{token}",
+ }
+ end
+
+ def client_registration_request_body(key_token)
+ params[:app] ||= params[:custom_broker_app] || Rails.configuration.default_tool
+ return if params[:app] == 'default' || params[:custom_broker_app] == 'default'
+
+ jwks_uri = dynamic_registration_pubkeyset_url(key_token: key_token)
+
+ tool = Rails.configuration.default_tool
+
+ {
+ "application_type": 'web',
+ "response_types": ['id_token'],
+ "grant_types": %w[implict client_credentials],
+ "initiate_login_uri": openid_login_url(protocol: 'https'),
+ "redirect_uris":
+ [openid_launch_url(protocol: 'https'),
+ deep_link_request_launch_url(protocol: 'https'),],
+ "client_name": t("apps.#{tool}.title"),
+ "jwks_uri": jwks_uri,
+ # "logo_uri": 'https://client.example.org/logo.png',
+ # "policy_uri": 'https://client.example.org/privacy',
+ # "policy_uri#ja": 'https://client.example.org/privacy?lang=ja',
+ # "tos_uri": 'https://client.example.org/tos',
+ # "tos_uri#ja": 'https://client.example.org/tos?lang=ja',
+ "token_endpoint_auth_method": 'private_key_jwt',
+ # "contacts": ['ve7jtb@example.org', 'mary@example.org'],
+ "scope": 'https://purl.imsglobal.org/spec/lti-ags/scope/score https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly',
+ "https://purl.imsglobal.org/spec/lti-tool-configuration": {
+ "domain": URI.parse(openid_launch_url(protocol: 'https')).host,
+ "description": t("apps.#{tool}.description"),
+ "target_link_uri": openid_launch_url(protocol: 'https'),
+ "custom_parameters": {},
+ "claims": %w[iss sub name given_name family_name email],
+ "messages": [
+ {
+ "type": 'LtiDeepLinkingRequest',
+ "target_link_uri": deep_link_request_launch_url(protocol: 'https'),
+ "label": 'Add a tool',
+ },
+ ],
+ },
+ }
+ end
+
+ def dynamic_registration_resource(url, title, custom_params = {})
+ {
+ 'type' => 'ltiResourceLink',
+ 'title' => title,
+ 'url' => url,
+ 'presentation' => {
+ 'documentTarget' => 'window',
+ },
+ 'custom' => custom_params,
+ }
+ end
+
+ def dynamic_registration_jwt_response(registration, jwt_header, jwt_body, resources)
+ message = {
+ 'iss' => registration['client_id'],
+ 'aud' => [registration['issuer']],
+ 'exp' => Time.now.to_i + 600,
+ 'iat' => Time.now.to_i,
+ 'nonce' => "nonce#{SecureRandom.hex}",
+ 'https://purl.imsglobal.org/spec/lti/claim/deployment_id' => jwt_body['https://purl.imsglobal.org/spec/lti/claim/deployment_id'],
+ 'https://purl.imsglobal.org/spec/lti/claim/message_type' => 'LtiDeepLinkingResponse',
+ 'https://purl.imsglobal.org/spec/lti/claim/version' => '1.3.0',
+ 'https://purl.imsglobal.org/spec/lti-dl/claim/content_items' => resources,
+ 'https://purl.imsglobal.org/spec/lti-dl/claim/data' => jwt_body['https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings']['data'],
+ }
+
+ message.each do |key, value|
+ message[key] = '' if value.nil?
+ end
+
+ priv = File.read(registration['tool_private_key'])
+ priv_key = OpenSSL::PKey::RSA.new(priv)
+
+ JWT.encode(message, priv_key, 'RS256', kid: jwt_header['kid'])
+ end
+
+ def validate_registration_initiation_request
+ # openid_configuration: the endpoint to the open id configuration to be used for this registration, encoded as per [RFC3986] Section 3.4.
+ raise CustomError, :openid_configuration_not_found unless params.key?('openid_configuration')
+ # registration_token (optional): the registration access token. If present, it must be used as the access token by the tool when making
+ # the registration request to the registration endpoint exposed in the openid configuration.
+ raise CustomError, :registration_token_not_found unless params.key?('registration_token')
+
+ begin
+ jwt_parts = validate_jwt_format
+ jwt_header = JSON.parse(Base64.urlsafe_decode64(jwt_parts[0]))
+ jwt_body = JSON.parse(Base64.urlsafe_decode64(jwt_parts[1]))
+
+ logger.debug("jwt.header:\n#{jwt_header.inspect}")
+ logger.debug("jwt.body:\n#{jwt_body.inspect}")
+ rescue StandardError
+ raise CustomError, :jwt_error
+ end
+
+ {
+ header: jwt_header,
+ body: jwt_body,
+ }
+ end
+
+ # Generate a new RSA key pair and returnss the key_token as a reference.
+ def new_rsa_keypair
+ # Setting keys
+ private_key = OpenSSL::PKey::RSA.generate(4096)
+ public_key = private_key.public_key
+
+ key_token = Digest::MD5.hexdigest(SecureRandom.uuid)
+ Dir.mkdir('.ssh/') unless Dir.exist?('.ssh/')
+ Dir.mkdir(".ssh/#{key_token}") unless Dir.exist?(".ssh/#{key_token}")
+
+ File.open(Rails.root.join(".ssh/#{key_token}/priv_key"), 'w') do |f|
+ f.puts(private_key.to_s)
+ end
+
+ File.open(Rails.root.join(".ssh/#{key_token}/pub_key"), 'w') do |f|
+ f.puts(public_key.to_s)
+ end
+
+ key_token
+ end
+
+ private
+
+ def validate_jwt_format
+ jwt_parts = params[:registration_token].split('.')
+ raise CustomError, :invalid_id_token unless jwt_parts.length == 3
+
+ jwt_parts
+ end
+end
diff --git a/app/controllers/concerns/open_id_authenticator.rb b/app/controllers/concerns/open_id_authenticator.rb
index d9d5612a..9ef5bb01 100644
--- a/app/controllers/concerns/open_id_authenticator.rb
+++ b/app/controllers/concerns/open_id_authenticator.rb
@@ -23,7 +23,7 @@ module OpenIdAuthenticator
include ExceptionHandler
def verify_openid_launch
- validate_openid_message_state
+ validate_openid_message_state unless params.key?('registration_token')
jwt_parts = validate_jwt_format
jwt_header = JSON.parse(Base64.urlsafe_decode64(jwt_parts[0]))
diff --git a/app/controllers/message_controller.rb b/app/controllers/message_controller.rb
index e2ee4ff2..198f4d84 100644
--- a/app/controllers/message_controller.rb
+++ b/app/controllers/message_controller.rb
@@ -37,7 +37,7 @@ class MessageController < ApplicationController
before_action :lti_authorized_application, only: %i[basic_lti_launch_request basic_lti_launch_request_legacy]
# validates message with oauth in rails lti2 provider gem
before_action :lti_authentication, only: %i[basic_lti_launch_request basic_lti_launch_request_legacy]
- # validates message corresponds to a LTI launch
+ # validates message corresponds to a LTI request
before_action :process_openid_message, only: %i[openid_launch_request deep_link]
# fails lti_authentication in rails lti2 provider gem
@@ -141,7 +141,7 @@ def content_item_selection
def openid_launch_request
## The launch for LTI 1.3 sets params[:app] and redirectos to the corresponding app. The default tool is assigned if the parameter is not included.
params[:app] ||= params[:custom_broker_app] || Rails.configuration.default_tool
- return if params[:app] == 'default' || params[:broker_custom_app] == 'default'
+ return if params[:app] == 'default' || params[:custom_broker_app] == 'default'
params[:oauth_nonce] = @jwt_body['nonce']
params[:oauth_consumer_key] = @jwt_body['iss']
diff --git a/app/controllers/registration_controller.rb b/app/controllers/registration_controller.rb
new file mode 100644
index 00000000..ab34b377
--- /dev/null
+++ b/app/controllers/registration_controller.rb
@@ -0,0 +1,176 @@
+# frozen_string_literal: true
+
+# BigBlueButton open source conferencing system - http://www.bigbluebutton.org/.
+
+# Copyright (c) 2018 BigBlueButton Inc. and by respective authors (see below).
+
+# This program is free software; you can redistribute it and/or modify it under the
+# terms of the GNU Lesser General Public License as published by the Free Software
+# Foundation; either version 3.0 of the License, or (at your option) any later
+# version.
+
+# BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+# PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
+
+# You should have received a copy of the GNU Lesser General Public License along
+# with BigBlueButton; if not, see .
+
+# Used to validate oauth signatures
+require 'oauth/request_proxy/action_controller_request'
+
+class RegistrationController < ApplicationController
+ include RailsLti2Provider::ControllerHelpers
+ include ExceptionHandler
+ include OpenIdAuthenticator
+ include AppsValidator
+ include LtiHelper
+ include PlatformValidator
+ include DynamicRegistrationService
+
+ before_action :print_parameters if Rails.configuration.developer_mode_enabled
+ # skip rail default verify auth token - we use our own strategies
+ skip_before_action :verify_authenticity_token
+ # validates message corresponds to a LTI request
+ before_action :process_registration_initiation_request, only: %i[dynamic]
+
+ @error_message = ''
+ @error_suggestion = ''
+
+ rescue_from ExceptionHandler::CustomError do |ex|
+ @error = case ex.error
+ when :tenant_not_found, :tool_duplicated
+ { code: '406',
+ key: t('error.http._406.code'),
+ message: @error_message,
+ suggestion: @error_suggestion || '',
+ status: '406', }
+ else
+ { code: '520',
+ key: t('error.http._520.code'),
+ message: t('error.http._520.message'),
+ suggestion: t('error.http._520.suggestion'),
+ status: '520', }
+ end
+ logger.error("Registration error:\n#{@error.to_yaml}")
+ render 'errors/index'
+ end
+
+ def dynamic
+ # 3.7 Step 4: Registration Completed and Activation
+ # Once the registration is completed, successfully or not, the tool should notify the platform by sending an HTML5 Web Message
+ # [webmessaging] indicating the window may be closed. Depending on whether the platform opened the registration in an IFrame or
+ # a new tab, either window.parent or window.opener should be called.
+ end
+
+ def pub_keyset
+ # The param :key_token is required. It should fail if not included. IT should also fail if not found.
+ key_token = params[:key_token]
+ tool_public_key = Rails.root.join(".ssh/#{key_token}/pub_key")
+ pub = File.read(tool_public_key)
+ pub_key = OpenSSL::PKey::RSA.new(pub)
+
+ # lookup for the kid
+ tool = RailsLti2Provider::Tool.where('tool_settings LIKE ?', "%#{key_token}%").first
+ tool_settings = JSON.parse(tool.tool_settings)
+ jwt_parts = tool_settings['registration_token'].split('.')
+ jwt_header = JSON.parse(Base64.urlsafe_decode64(jwt_parts[0]))
+
+ # prepare the pub_keyset
+ json_pub_keyset = {}
+ json_pub_keyset['keys'] = [
+ {
+ kty: 'RSA',
+ e: Base64.urlsafe_encode64(pub_key.e.to_s(2)).delete('='), # Exponent
+ n: Base64.urlsafe_encode64(pub_key.n.to_s(2)).delete('='), # Modulus
+ kid: jwt_header['kid'],
+ alg: 'RS256',
+ use: 'sig',
+ },
+ ]
+
+ render(json: JSON.pretty_generate(json_pub_keyset))
+ end
+
+ private
+
+ # verify lti 1.3 dynamic registration request
+ def process_registration_initiation_request
+ # Step 0: Validate the existent of the tenant
+ # TODO: There should be a onetime code that allows registration under specific tenant.
+ # for now on first registration, the tool is linked to the 'default' tenant, which must exist.
+ tenant_uid = ''
+ # only works if the targeted tenant exists. By default it will lookup for uid=''
+ unless RailsLti2Provider::Tenant.exists?(uid: tenant_uid)
+ @error_message = "Tenant with uid = '#{tenant_uid}' does not exist"
+ raise CustomError, :tenant_not_found
+ end
+
+ tenant = RailsLti2Provider::Tenant.find_by(uid: tenant_uid)
+
+ # 3.3 Step 1: Registration Initiation Request
+ begin
+ jwt = validate_registration_initiation_request
+ @jwt_header = jwt[:header]
+ @jwt_body = jwt[:body]
+ rescue StandardError => e
+ @error_message = "Error in registrtion initiation request verification: #{e}"
+ raise CustomError, :registration_verification_failed
+ end
+
+ # 3.4 Step 2: Discovery and openid Configuration
+ openid_configuration = discover_openid_configuration(params['openid_configuration'])
+
+ # scope can be @jwt_body['scope'] == 'reg' or @jwt_body['scope'] == 'reg-update'
+ if RailsLti2Provider::Tool.exists?(uuid: openid_configuration['issuer'], tenant: tenant) && @jwt_body['scope'] == 'reg'
+ @error_message = "Issuer or Platform ID has already been registered for tenant '#{tenant.uid}'"
+ raise CustomError, :tool_duplicated
+ end
+
+ # 3.5 Step 3: Client Registration
+ uri = URI(openid_configuration['registration_endpoint'])
+ # 3.5.1 Issuer and OpenID Configuration URL Match
+ # validate_issuer(jwt_body)
+
+ # 3.5.2 Client Registration Request
+ # TODO: old keys should be removed when @jwt_body['scope'] == 'reg-update'
+ key_token = new_rsa_keypair
+ header = client_registration_request_header(params[:registration_token])
+ body = client_registration_request_body(key_token)
+ body = body.to_json
+
+ http = Net::HTTP.new(uri.host, uri.port)
+ http.use_ssl = (uri.scheme == 'https')
+ request = Net::HTTP::Post.new(uri, header)
+ request.body = body
+
+ response = http.request(request)
+ response = JSON.parse(response.body)
+
+ # 3.6 Client Registration Response
+ reg = {
+ issuer: openid_configuration['issuer'],
+ client_id: response['client_id'],
+ key_set_url: openid_configuration['jwks_uri'],
+ auth_token_url: openid_configuration['token_endpoint'],
+ auth_login_url: openid_configuration['authorization_endpoint'],
+ tool_private_key: Rails.root.join(".ssh/#{key_token}/priv_key"),
+ registration_token: params[:registration_token],
+ }
+
+ tool = RailsLti2Provider::Tool.find_or_create_by(uuid: openid_configuration['issuer'], tenant: tenant)
+ tool.shared_secret = response['client_id']
+ tool.tool_settings = reg.to_json
+ tool.lti_version = '1.3.0'
+ tool.status = 'enabled'
+ tool.save
+ # 3.6.2 Client Registration Error Response
+
+ # 3.6.1 Successful Registration
+ logger.debug(tool.to_yaml)
+ end
+
+ def discover_openid_configuration(url)
+ JSON.parse(URI.parse(url).read)
+ end
+end
diff --git a/app/controllers/tool_profile_controller.rb b/app/controllers/tool_profile_controller.rb
index 95ddd3c8..61187b64 100644
--- a/app/controllers/tool_profile_controller.rb
+++ b/app/controllers/tool_profile_controller.rb
@@ -68,7 +68,7 @@ def json_config
@json_config['public_jwk'] = jwk
@json_config['extensions'][0]['settings']['domain'] = request.base_url
- @json_config['extensions'][0]['settings']['tool_id'] = Digest::MD5.hexdigest(request.base_url)
+ @json_config['extensions'][0]['settings']['tool_id'] = Digest::MD5.hexdigest(SecureRandom.uuid)
@json_config['extensions'][0]['settings']['icon_url'] = lti_icon(params[:app])
@json_config['extensions'][0]['settings']['placements'].each do |placement|
diff --git a/app/views/registration/dynamic.html.erb b/app/views/registration/dynamic.html.erb
new file mode 100644
index 00000000..12330a32
--- /dev/null
+++ b/app/views/registration/dynamic.html.erb
@@ -0,0 +1,21 @@
+<%# BigBlueButton open source conferencing system - http://www.bigbluebutton.org/.
+
+Copyright (c) 2018 BigBlueButton Inc. and by respective authors (see below).
+
+This program is free software; you can redistribute it and/or modify it under the
+terms of the GNU Lesser General Public License as published by the Free Software
+Foundation; either version 3.0 of the License, or (at your option) any later
+version.
+
+BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY
+WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
+
+You should have received a copy of the GNU Lesser General Public License along
+with BigBlueButton; if not, see . %>
+
+<%= render "layouts/header_iframe" %>
+
+
Dynamic registration
+
+<%= render "layouts/footer_iframe" %>
diff --git a/config/locales/en.yml b/config/locales/en.yml
index fc52abd6..6982b311 100755
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -41,7 +41,7 @@ en:
copypaste: "Paste this url into your tool consumer to register this tool:"
apps:
default:
- title: "LTI Tool Provider"
+ title: "Default"
description: "General Tool Provider that allows users to test LTI 1.x and 2.x capabilities"
rooms:
title: "Rooms"
@@ -94,6 +94,10 @@ en:
message: "Unauthorized access"
suggestion: "If you are using this room through LTI try launching the app again by using the link or refreshing the browser"
_406:
+ code: "Not Acceptable"
+ message: "This request cannot be processed"
+ suggestion: "Contact Tool Administrator."
+ _406.1:
code: "Invalid Authenticity Token"
message: "Can't verify CSRF token authenticity"
suggestion: "You may be facing this error because the plugin is not SSL secured, or because third party cookies are not enabled."
diff --git a/config/routes.rb b/config/routes.rb
index 32f613c2..af0eb96b 100755
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -69,6 +69,9 @@
post 'tool/messages/content-item', to: 'message#basic_lti_launch_request', as: 'content_item_launch'
post 'tool/messages/deep-link', to: 'message#deep_link', as: 'deep_link_request_launch'
post 'tool/messages/signed_content_item_request', to: 'message#signed_content_item_request'
+ # dynamic registration go through this paths
+ get 'tool/registration', to: 'registration#dynamic', as: :dynamic_registration
+ get 'tool/registration/pubkeyset/(:key_token)', to: 'registration#pub_keyset', as: :dynamic_registration_pubkeyset
match 'tool/json_config/:temp_key_token', to: 'tool_profile#json_config', via: [:get, :post], as: 'json_config' # , :defaults => {:format => 'json'}
diff --git a/lib/tasks/registration.rake b/lib/tasks/registration.rake
index cd41aace..920c7de1 100644
--- a/lib/tasks/registration.rake
+++ b/lib/tasks/registration.rake
@@ -59,12 +59,8 @@ namespace :registration do
private_key = OpenSSL::PKey::RSA.generate(4096)
public_key = private_key.public_key
- jwk = JWT::JWK.new(private_key).export
- jwk['alg'] = 'RS256' unless jwk.key?('alg')
- jwk['use'] = 'sig' unless jwk.key?('use')
- jwk = jwk.to_json
- key_dir = Digest::MD5.hexdigest(issuer + client_id)
+ key_dir = Digest::MD5.hexdigest(SecureRandom.uuid)
Dir.mkdir('.ssh/') unless Dir.exist?('.ssh/')
Dir.mkdir(".ssh/#{key_dir}") unless Dir.exist?(".ssh/#{key_dir}")
@@ -76,6 +72,12 @@ namespace :registration do
f.puts(public_key.to_s)
end
+ # Only required for the output for cases when the private key is required as a token.
+ jwk = JWT::JWK.new(private_key).export
+ jwk['alg'] = 'RS256' unless jwk.key?('alg')
+ jwk['use'] = 'sig' unless jwk.key?('use')
+ jwk = jwk.to_json
+
reg = {
issuer: issuer,
client_id: client_id,
@@ -265,7 +267,7 @@ namespace :registration do
jwk['use'] = 'sig' unless jwk.key?('use')
jwk = jwk.to_json
- key_dir = Digest::MD5.hexdigest(issuer + client_id)
+ key_dir = Digest::MD5.hexdigest(SecureRandom.uuid)
Dir.mkdir('.ssh/') unless Dir.exist?('.ssh/')
Dir.mkdir(".ssh/#{key_dir}") unless Dir.exist?(".ssh/#{key_dir}")