Skip to content

Commit

Permalink
LTI-292: Add support for LTI 1.3 Dynamic Registration (#204)
Browse files Browse the repository at this point in the history
* LTI-292: fist pass, still erroring

* LTI-292: solved issues that were making dynamic registration fail

* LTI-292: fixed format rendered by pubkeyset

* LTI-292: minor tweaks to rendered registration claim

* LTI-292: Fixed issue with pub_keyset

* LTI-292: Added some validations and pretified errors

* LTI-292: small rubocop
  • Loading branch information
jfederico committed Mar 27, 2024
1 parent 4f952c0 commit c76d8b8
Show file tree
Hide file tree
Showing 9 changed files with 379 additions and 11 deletions.
162 changes: 162 additions & 0 deletions app/controllers/concerns/dynamic_registration_service.rb
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

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": ['[email protected]', '[email protected]'],
"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
2 changes: 1 addition & 1 deletion app/controllers/concerns/open_id_authenticator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]))
Expand Down
4 changes: 2 additions & 2 deletions app/controllers/message_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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']
Expand Down
176 changes: 176 additions & 0 deletions app/controllers/registration_controller.rb
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

# 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
2 changes: 1 addition & 1 deletion app/controllers/tool_profile_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down
21 changes: 21 additions & 0 deletions app/views/registration/dynamic.html.erb
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>. %>
<%= render "layouts/header_iframe" %>

<h1>Dynamic registration</h1>

<%= render "layouts/footer_iframe" %>
Loading

0 comments on commit c76d8b8

Please sign in to comment.