From 0197b62000071439ef6f72d7a4972fefbaf1acac Mon Sep 17 00:00:00 2001
From: Moritz Strohm <strohm@data-quest.de>
Date: Fri, 10 Jan 2025 13:34:43 +0000
Subject: [PATCH] StEP 3348, closes #3348

Closes #3348

Merge request studip/studip!2275
---
 .gitignore                                    |    1 +
 app/controllers/admin/lti.php                 |   73 +-
 app/controllers/course/lti.php                |  661 +++++++---
 app/controllers/lti.php                       |  127 --
 app/controllers/lti/ags.php                   |  106 ++
 app/controllers/lti/auth.php                  |  250 ++++
 app/controllers/lti/tool.php                  |  236 ++++
 app/views/admin/lti/edit.php                  |   71 +-
 app/views/admin/lti/index.php                 |  114 +-
 app/views/course/lti/config.php               |   23 +-
 app/views/course/lti/consent.php              |   72 ++
 app/views/course/lti/edit.php                 |  117 --
 app/views/course/lti/grades.php               |   70 +-
 app/views/course/lti/grades_user.php          |   56 +-
 app/views/course/lti/iframe.php               |   52 +-
 app/views/course/lti/index.php                |  116 +-
 app/views/course/lti/select_link.php          |   15 +
 app/views/course/lti/select_tool.php          |   31 +
 app/views/lti/_deployment_user_info.php       |   43 +
 app/views/lti/_platform_data.php              |   45 +
 app/views/lti/_tool_form_fields.php           |  148 +++
 app/views/lti/_tool_info.php                  |   79 ++
 app/views/lti/tool/add.php                    |   16 +
 app/views/lti/tool/edit.php                   |   18 +
 app/views/lti/tool/index.php                  |    7 +
 composer.json                                 |    7 +-
 composer.lock                                 | 1064 +++++++++++++++--
 db/migrations/6.0.39_add_lti13a.php           |  235 ++++
 db/studip_default_data.sql                    |    1 -
 lib/classes/LTI13a/Identity.php               |  103 ++
 lib/classes/LTI13a/KeyChainFactory.php        |   56 +
 lib/classes/LTI13a/KeyManager.php             |   33 +
 lib/classes/LTI13a/LineItemRepository.php     |  192 +++
 lib/classes/LTI13a/NonceGenerator.php         |   34 +
 lib/classes/LTI13a/PlatformManager.php        |  106 ++
 lib/classes/LTI13a/Registration.php           |  122 ++
 lib/classes/LTI13a/RegistrationManager.php    |   67 ++
 lib/classes/LTI13a/ResultRepository.php       |   64 +
 lib/classes/LTI13a/ScoreRepository.php        |   29 +
 lib/classes/LTI13a/UserAuthenticator.php      |   38 +
 lib/classes/auth_plugins/StudipAuthLTI.php    |   42 +-
 lib/exceptions/KeyringException.php           |   27 +
 lib/exceptions/LTIException.php               |   17 +
 lib/models/Grading/Definition.php             |   31 +
 lib/models/Grading/Instance.php               |   14 +
 lib/models/Keyring.php                        |  169 +++
 lib/models/{LtiData.php => LtiDeployment.php} |   93 +-
 lib/models/LtiGrade.php                       |    4 +-
 lib/models/LtiTool.php                        |  154 ++-
 lib/models/LtiToolPrivacySettings.php         |   46 +
 lib/modules/CoreParticipants.php              |    8 +-
 lib/modules/LtiToolModule.php                 |   35 +-
 .../bootstrap/studip_helper_attributes.js     |   32 +-
 53 files changed, 4517 insertions(+), 853 deletions(-)
 delete mode 100644 app/controllers/lti.php
 create mode 100644 app/controllers/lti/ags.php
 create mode 100644 app/controllers/lti/auth.php
 create mode 100644 app/controllers/lti/tool.php
 create mode 100644 app/views/course/lti/consent.php
 delete mode 100644 app/views/course/lti/edit.php
 create mode 100644 app/views/course/lti/select_link.php
 create mode 100644 app/views/course/lti/select_tool.php
 create mode 100644 app/views/lti/_deployment_user_info.php
 create mode 100644 app/views/lti/_platform_data.php
 create mode 100644 app/views/lti/_tool_form_fields.php
 create mode 100644 app/views/lti/_tool_info.php
 create mode 100644 app/views/lti/tool/add.php
 create mode 100644 app/views/lti/tool/edit.php
 create mode 100644 app/views/lti/tool/index.php
 create mode 100644 db/migrations/6.0.39_add_lti13a.php
 create mode 100644 lib/classes/LTI13a/Identity.php
 create mode 100644 lib/classes/LTI13a/KeyChainFactory.php
 create mode 100644 lib/classes/LTI13a/KeyManager.php
 create mode 100644 lib/classes/LTI13a/LineItemRepository.php
 create mode 100644 lib/classes/LTI13a/NonceGenerator.php
 create mode 100644 lib/classes/LTI13a/PlatformManager.php
 create mode 100644 lib/classes/LTI13a/Registration.php
 create mode 100644 lib/classes/LTI13a/RegistrationManager.php
 create mode 100644 lib/classes/LTI13a/ResultRepository.php
 create mode 100644 lib/classes/LTI13a/ScoreRepository.php
 create mode 100644 lib/classes/LTI13a/UserAuthenticator.php
 create mode 100644 lib/exceptions/KeyringException.php
 create mode 100644 lib/exceptions/LTIException.php
 create mode 100644 lib/models/Keyring.php
 rename lib/models/{LtiData.php => LtiDeployment.php} (55%)
 create mode 100644 lib/models/LtiToolPrivacySettings.php

diff --git a/.gitignore b/.gitignore
index 81dec99469d..db284796086 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,7 @@ data/oer_logos/*
 data/upload_doc/*
 
 public/.htaccess
+public/.rnd
 public/assets/javascripts/*.js
 public/assets/javascripts/*.js.map
 public/assets/stylesheets/*.css
diff --git a/app/controllers/admin/lti.php b/app/controllers/admin/lti.php
index eb3082c5dc4..25ea3febe9c 100644
--- a/app/controllers/admin/lti.php
+++ b/app/controllers/admin/lti.php
@@ -23,16 +23,21 @@ class Admin_LtiController extends AuthenticatedController
         $GLOBALS['perm']->check('root');
 
         Navigation::activateItem('/admin/config/lti');
-        PageLayout::setTitle(_('Konfiguration der LTI-Tools'));
+        PageLayout::setTitle(_('LTI-Tools'));
 
         $widget = Sidebar::get()->addWidget(new ActionsWidget());
         $widget->addLink(
             _('Neues LTI-Tool registrieren'),
-            $this->url_for('admin/lti/edit'),
+            $this->url_for('lti/tool/add/global'),
             Icon::create('add')
         )->asDialog();
+        $widget->addLink(
+            _('Daten zur LTI-Plattform anzeigen'),
+            $this->url_for('lti/auth/platform_data'),
+            Icon::create('info')
+        )->asDialog();
 
-        Helpbar::get()->addPlainText('', _('Hier können Sie Verknüpfungen mit externen Tools konfigurieren, sofern diese den LTI-Standard (Version 1.x) unterstützen.'));
+        Helpbar::get()->addPlainText('', _('Hier können Sie LTI-Tools konfigurieren. Diese müssen den LTI-Standard in Version 1.0/1.1 oder 1.3A unterstützen.'));
     }
 
     /**
@@ -42,66 +47,4 @@ class Admin_LtiController extends AuthenticatedController
     {
         $this->tools = LtiTool::findAll();
     }
-
-    /**
-     * Display dialog for editing an LTI tool.
-     *
-     * @param   int $id tool id
-     */
-    public function edit_action($id = null)
-    {
-        $this->tool = new LtiTool($id);
-    }
-
-    /**
-     * Save changes for an LTI tool.
-     *
-     * @param   int $id tool id
-     */
-    public function save_action($id)
-    {
-        CSRFProtection::verifyUnsafeRequest();
-
-        $tool = new LtiTool($id ?: null);
-        $tool->name = trim(Request::get('name'));
-        $tool->launch_url = trim(Request::get('launch_url'));
-        $tool->consumer_key = trim(Request::get('consumer_key'));
-        $tool->consumer_secret = trim(Request::get('consumer_secret'));
-        $tool->custom_parameters = trim(Request::get('custom_parameters'));
-        $tool->allow_custom_url = Request::int('allow_custom_url', 0);
-        $tool->deep_linking = Request::int('deep_linking', 0);
-        $tool->send_lis_person = Request::int('send_lis_person', 0);
-        $tool->oauth_signature_method = Request::get('oauth_signature_method', 'sha1');
-
-        if ($tool->store()) {
-            PageLayout::postSuccess(sprintf(
-                _('Einstellungen für "%s" wurden gespeichert.'),
-                htmlReady($tool->name)
-            ));
-        }
-
-        $this->redirect('admin/lti');
-    }
-
-    /**
-     * Delete an LTI tool.
-     *
-     * @param   int $id tool id
-     */
-    public function delete_action($id)
-    {
-        CSRFProtection::verifyUnsafeRequest();
-
-        $tool = LtiTool::find($id);
-        $tool_name = $tool->name;
-
-        if ($tool && $tool->delete()) {
-            PageLayout::postSuccess(sprintf(
-                _('Das LTI-Tool "%s" wurde gelöscht.'),
-                htmlReady($tool_name)
-            ));
-        }
-
-        $this->redirect('admin/lti');
-    }
 }
diff --git a/app/controllers/course/lti.php b/app/controllers/course/lti.php
index ab403af6f50..5fe73d1ef91 100644
--- a/app/controllers/course/lti.php
+++ b/app/controllers/course/lti.php
@@ -1,6 +1,17 @@
 <?php
 
-use Studip\OAuth2\NegotiatesWithPsr7;
+use OAT\Library\Lti1p3Core\Message\Launch\Builder\LtiResourceLinkLaunchRequestBuilder;
+use OAT\Library\Lti1p3Core\Message\Launch\Validator\Platform\PlatformLaunchValidator;
+use OAT\Library\Lti1p3Core\Message\Payload\Claim\AgsClaim;
+use OAT\Library\Lti1p3Core\Message\Payload\Claim\ContextClaim;
+use OAT\Library\Lti1p3Core\Resource\LtiResourceLink\LtiResourceLink;
+use OAT\Library\Lti1p3Core\Resource\LtiResourceLink\LtiResourceLinkInterface;
+use OAT\Library\Lti1p3Core\Security\Nonce\NonceRepository;
+use OAT\Library\Lti1p3DeepLinking\Factory\ResourceCollectionFactory;
+use OAT\Library\Lti1p3DeepLinking\Message\Launch\Builder\DeepLinkingLaunchRequestBuilder;
+use Studip\LTI13a\PlatformManager;
+use Studip\LTI13a\Registration;
+use Studip\LTI13a\RegistrationManager;
 
 /**
  * course/lti.php - LTI consumer API for Stud.IP
@@ -16,7 +27,7 @@ use Studip\OAuth2\NegotiatesWithPsr7;
 
 class Course_LtiController extends StudipController
 {
-    use NegotiatesWithPsr7;
+    use Studip\OAuth2\NegotiatesWithPsr7;
 
     public function __construct(\Trails\Dispatcher $dispatcher)
     {
@@ -34,23 +45,29 @@ class Course_LtiController extends StudipController
     public function before_filter(&$action, &$args)
     {
         parent::before_filter($action, $args);
-        // these actions do not require session authentication
+        //The profile and outcome actions do not require all the other
+        //stuff that is going on in this method:
         if (in_array($action, ['profile', 'outcome'])) {
             return;
         }
         $this->course_id = Context::getId();
-        $this->edit_perm = $GLOBALS['perm']->have_studip_perm('tutor', $this->course_id);
+        $this->course = Course::find($this->course_id);
 
-        if (!in_array($action, ['index', 'iframe', 'grades']) && !$this->edit_perm) {
-            throw new AccessDeniedException(_('Sie besitzen keine Berechtigung, um LTI-Tools zu konfigurieren.'));
+        if (in_array($action, ['select_tool', 'add_link']) && !$this->course) {
+            throw new AccessDeniedException();
         }
 
-        if ($action !== 'grades') {
-            Navigation::activateItem('/course/lti/index');
+        $this->edit_perm = $GLOBALS['perm']->have_studip_perm('tutor', $this->course_id);
+        if (!in_array($action, ['index', 'iframe', 'grades', 'consent']) && !$this->edit_perm) {
+            throw new AccessDeniedException();
         }
 
-        $title = CourseConfig::get($this->course_id)->LTI_TOOL_TITLE;
-        PageLayout::setTitle(Context::getHeaderLine() . ' - ' . $title);
+        if (
+            !in_array($action, ['admin', 'grades'])
+            && Navigation::hasItem('/course/lti/index')
+        ) {
+            Navigation::activateItem('/course/lti/index');
+        }
     }
 
     /**
@@ -58,7 +75,17 @@ class Course_LtiController extends StudipController
      */
     public function index_action()
     {
-        $this->lti_data_array = LtiData::findByCourse_id($this->course_id, 'ORDER BY position');
+        $this->lti_data_array = [];
+        if ($this->edit_perm) {
+            $this->lti_data_array = LtiDeployment::findByCourse_id($this->course_id, 'ORDER BY position');
+        } else {
+            //Only load those deployments that are fully configured:
+            $this->lti_data_array = LtiDeployment::findBySQL(
+                "`course_id` = :course_id AND (`options` IS NULL OR `options` NOT LIKE '%unfinished_deep_linking%')
+                ORDER BY `position`",
+                ['course_id' => $this->course_id]
+            );
+        }
 
         if ($this->edit_perm) {
             $widget = Sidebar::get()->addWidget(new ActionsWidget());
@@ -67,17 +94,21 @@ class Course_LtiController extends StudipController
                 $this->url_for('course/lti/config'),
                 Icon::create('admin')
             )->asDialog('size=auto');
-            $widget->addLink(
-                _('Abschnitt hinzufügen'),
-                $this->url_for('course/lti/edit'),
-                Icon::create('add')
-            )->asDialog();
+            $global_tools_available = LtiTool::countBySQL("`range_id` = 'global'") > 0;
+            if (Config::get()->LTI_ALLOW_TOOL_CONFIG_IN_COURSE || $global_tools_available) {
+                $widget->addLink(
+                    _('LTI-Tool hinzufügen'),
+                    $this->url_for('course/lti/select_tool'),
+                    Icon::create('add')
+                )->asDialog('size=auto');
+            }
 
-            if (LtiTool::findByDeep_linking(1)) {
+            $global_deep_linking_tools_exist = LtiTool::countBySQL("`deep_linking` = 1 AND `range_id` = 'global'") > 0;
+            if ($global_deep_linking_tools_exist) {
                 $widget->addLink(
-                    _('Link aus LTI-Tool einfügen'),
+                    _('Tool mittels LTI Deep Linking hinzufügen'),
                     $this->url_for('course/lti/add_link'),
-                    Icon::create('add')
+                    Icon::create('network2')
                 )->asDialog('size=auto');
             }
         }
@@ -85,126 +116,257 @@ class Course_LtiController extends StudipController
         Helpbar::get()->addPlainText('', _('Auf dieser Seite können Sie externe Anwendungen einbinden, sofern diese den LTI-Standard (Version 1.x) unterstützen.'));
     }
 
-    /**
-     * Display the launch form for a tool as an iframe.
-     */
-    public function iframe_action(string $position)
+    public function select_tool_action()
     {
-        $lti_data = LtiData::findByCourseAndPosition($this->course_id, $position);
-        $lti_link = $this->getLtiLink($lti_data);
+        //The permission check is done in the before filter.
 
-        $this->launch_url = $lti_data->getLaunchURL();
-        $this->launch_data = $lti_link->getBasicLaunchData();
-        $this->signature = $lti_link->getLaunchSignature($this->launch_data);
+        $this->global_tools = LtiTool::findBySQL("`lti_version` = '1.3a' AND `range_id` = 'global' ORDER BY `name` ASC");
 
-        $this->set_layout(null);
+        if (!$this->global_tools) {
+            if (!Config::get()->LTI_ALLOW_TOOL_CONFIG_IN_COURSE) {
+                PageLayout::postError(_('Es sind keine globalen LTI-Tools konfiguriert, die in dieser Veranstaltung eingebunden werden können.'));
+                return;
+            }
+            //Redirect to the page to configure an LTI tool for the course:
+            $this->redirect('lti/tool/add/' . $this->course->id);
+        }
+
+        $this->selected_tool_id = '';
+        if (count($this->global_tools) >= 1) {
+            //Preselect the first tool:
+            $this->selected_tool_id = $this->global_tools[0]->id;
+        }
     }
 
-    /**
-     * Edit the course settings.
-     */
-    public function config_action()
+    public function select_tool_redirect_action()
     {
-        $this->title = CourseConfig::get($this->course_id)->LTI_TOOL_TITLE;
+        if (Request::isPost()) {
+            CSRFProtection::verifyUnsafeRequest();
+            $selected_tool_id = Request::get('selected_tool_id');
+            if ($selected_tool_id === 'new') {
+                //Redirect to the page to configure an LTI tool for the course:
+                $this->redirect('lti/tool/add/' . $this->course->id);
+            } else {
+                //Load the selected tool and check if it can be used in the course.
+                $selected_tool = LtiTool::find($selected_tool_id);
+                if (!$selected_tool || $selected_tool->range_id !== 'global') {
+                    PageLayout::postError(_('Das ausgewählte LTI-Tool kann nicht genutzt werden.'));
+                    $this->redirect('course/lti/select_tool');
+                    return;
+                }
+                $this->redirect('lti/tool/add/' . $this->course->id . '/' . $selected_tool->id);
+            }
+        } else {
+            $this->redirect('course/lti/select_tool');
+        }
     }
 
-    /**
-     * Save the course settings.
-     */
-    public function save_config_action()
+    public function consent_action(string $deployment_id)
     {
-        CSRFProtection::verifyUnsafeRequest();
+        $this->deployment = LtiDeployment::find($deployment_id);
+        if (!$this->deployment) {
+            PageLayout::postError(_('Die Einbindung eines LTI-Tools ist ungültig.'));
+            return;
+        }
 
-        $title = trim(Request::get('title'));
-        CourseConfig::get($this->course_id)->store('LTI_TOOL_TITLE', $title);
+        $this->privacy_settings = LtiToolPrivacySettings::findOneBySQL(
+            'tool_id = :tool_id AND user_id = :user_id',
+            ['tool_id' => $this->deployment->tool_id, 'user_id' => $GLOBALS['user']->id]
+        );
+        if (!$this->privacy_settings) {
+            $this->privacy_settings = new LtiToolPrivacySettings();
+            $this->privacy_settings->tool_id = $this->deployment->tool_id;
+            $this->privacy_settings->user_id = $GLOBALS['user']->id;
+        }
 
-        PageLayout::postSuccess(_('Die Einstellungen wurden gespeichert.'));
-        $this->redirect('course/lti');
+        if (Request::isPost()) {
+            CSRFProtection::verifyUnsafeRequest();
+            if (Request::submitted('save')) {
+                if (!Request::get('confirmed')) {
+                    PageLayout::postError(_('Ohne die aktive Zustimmung zur Weitergabe Ihrer personenbezogenen Daten können Sie das LTI-Tool nicht nutzen!'));
+                    return;
+                }
+                //Save the privacy settings and redirect to the tool:
+                $this->privacy_settings->accepted = '1';
+
+                //Check which optional fields are allowed to be transmitted to the tool:
+                $optional_field_list = Request::getArray('submit_optional_field', []);
+                $optional_fields = [];
+                if (array_key_exists('lang', $optional_field_list)) {
+                    $optional_fields[] = 'lang';
+                }
+                if (array_key_exists('avatar_url', $optional_field_list)) {
+                    $optional_fields[] = 'avatar_url';
+                }
+                $this->privacy_settings->allowed_optional_fields = implode(',', $optional_fields);
+                //Store the privacy settings:
+                $this->privacy_settings->store();
+            }
+            if (Request::isDialog()) {
+                //Close the dialog:
+                $this->response->add_header('X-Dialog-Close', '1');
+            } elseif (Request::submitted('redirect_to_tool') && Request::submitted('save')) {
+                //Redirect to the tool launch action, but only after the privacy settings have been saved:
+                $this->redirect('course/lti/iframe/' . $this->deployment->id);
+            } else {
+                //Redirect to the LTI tool page of the course:
+                $this->redirect('course/lti/index');
+            }
+        }
     }
 
     /**
-     * Move an LTI content block (either up or down).
-     *
-     * @param   int $position   block position
-     * @param   string $direction 'up' or 'down'
+     * Display the launch form for a tool as an iframe.
      */
-    public function move_action($position, $direction)
+    public function iframe_action(string $deployment_id)
     {
-        CSRFProtection::verifyUnsafeRequest();
-
-        if ($direction === 'up') {
-            $position2 = $position - 1;
-        } else {
-            $position2 = $position + 1;
+        $this->deployment = LtiDeployment::find($deployment_id);
+        $this->show_data_protection_info = !LtiToolPrivacySettings::countBySQL(
+            "`tool_id` = :tool_id AND `user_id` = :user_id AND `accepted` = 1",
+            ['tool_id' => $this->deployment->tool_id, 'user_id' => $GLOBALS['user']->id]
+        );
+        if ($this->show_data_protection_info) {
+            $this->redirect('course/lti/consent/' . $deployment_id, ['redirect_to_tool' => '1']);
+            return;
         }
 
-        $lti_data = LtiData::findByCourseAndPosition($this->course_id, $position);
-        $lti_data2 = LtiData::findByCourseAndPosition($this->course_id, $position2);
-
-        if ($lti_data && $lti_data2) {
-            $lti_data->position = $position2;
-            $lti_data->store();
-
-            $lti_data2->position = $position;
-            $lti_data2->store();
+        if (!$this->show_data_protection_info) {
+            //Redirect to the tool.
+            $this->lti13a_mode = false;
+            $lti_version = $this->deployment->getToolLtiVersion();
+            if ($lti_version === '1.3a') {
+                //LTI 1.3a
+                $this->lti13a_mode = true;
+
+                $lti_resource_link = new LtiResourceLink(
+                    $this->deployment->tool_id . '_' . $this->deployment->id . '_' . $this->course_id,
+                    [
+                        'url' => $this->deployment->getLaunchURL(),
+                        'title' => $this->deployment->title
+                    ]
+                );
+
+                $registration = new Registration($this->deployment->tool);
+                $builder = new LtiResourceLinkLaunchRequestBuilder();
+
+                //The AGS URLs need several parameters:
+                $ags_url_parameters = [
+                    'cid'           => $this->course_id,
+                    'tool_id'       => $this->deployment->tool_id,
+                    'deployment_id' => $this->deployment->id,
+                    'cancel_login'  => '1'
+                ];
+
+                //Build the message:
+                $this->message = $builder->buildLtiResourceLinkLaunchRequest(
+                    $lti_resource_link,
+                    $registration,
+                    $GLOBALS['user']->id,
+                    $this->deployment->id,
+                    [
+                        PlatformManager::getLtiRoleClaimForStudipRole($GLOBALS['perm']->get_studip_perm($this->course_id))
+                    ],
+                    array_merge(
+                        [
+                            new ContextClaim(
+                                $this->course_id,
+                                ['http://purl.imsglobal.org/vocab/lis/v2/course#CourseOffering'],
+                                $this->course->veranstaltungsnummer ?? '',
+                                $this->course?->getFullName() ?? ''
+                            ),
+                            new AgsClaim(
+                                [
+                                    'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem',
+                                    'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly',
+                                    'https://purl.imsglobal.org/spec/lti-ags/scope/score'
+                                ],
+                                $this->url_for('lti/ags/line_items', $ags_url_parameters),
+                                $this->url_for('lti/ags/line_item', $ags_url_parameters)
+                            )
+                        ],
+                        $this->deployment->getCustomLtiParameterArray(),
+                    )
+                );
+            } else {
+                //LTI 1.0/1.1
+                $lti_link = $this->getLtiLink($this->deployment);
+                $this->launch_url = $this->deployment->getLaunchURL();
+                $this->launch_data = $lti_link->getBasicLaunchData();
+                $this->signature = $lti_link->getLaunchSignature($this->launch_data);
+            }
+            $this->set_layout(null);
         }
+    }
 
-        $this->redirect('course/lti');
+    /**
+     * Edit the course settings.
+     */
+    public function config_action()
+    {
+        $course_config = CourseConfig::get($this->course_id);
+        $this->personal_data_warning = $course_config->LTI_DATA_PROTECTION_COURSE_WARNING;
+        if (empty($this->personal_data_warning)) {
+            $this->personal_data_warning = Config::get()->LTI_DATA_PROTECTION_DEFAULT_WARNING;
+        }
     }
 
     /**
-     * Edit an LTI content block (using a dialog window).
-     *
-     * @param   int $position   block position (blank: create a new block)
+     * Save the course settings.
      */
-    public function edit_action($position = '')
+    public function save_config_action()
     {
-        $this->lti_data = new LtiData();
+        CSRFProtection::verifyUnsafeRequest();
+
+        $course_config = CourseConfig::get($this->course_id);
 
-        if ($position !== '') {
-            $this->lti_data = LtiData::findByCourseAndPosition($this->course_id, $position);
+        if (Request::bool('reset_warning')) {
+            $course_config->delete('LTI_DATA_PROTECTION_COURSE_WARNING');
+        } else {
+            $course_config->store(
+                'LTI_DATA_PROTECTION_COURSE_WARNING',
+                trim(Request::get('personal_data_warning'))
+            );
         }
 
-        $this->tools = LtiTool::findAll();
+        PageLayout::postSuccess(_('Die Einstellungen wurden gespeichert.'));
+        $this->redirect('course/lti');
     }
 
     /**
-     * Save an LTI content block.
+     * Moves an LTI deployment in a course either up or down.
+     *
+     * @param string $deployment_id The ID of the deployment to be moved.
      *
-     * @param   int $position   block position (blank: create a new block)
+     * @param string $direction 'up' for moving the deployment upwards or 'down' for downwards.
      */
-    public function save_action($position)
+    public function move_action(string $deployment_id, string $direction)
     {
         CSRFProtection::verifyUnsafeRequest();
 
-        if ($position !== '') {
-            $lti_data = LtiData::findByCourseAndPosition($this->course_id, $position);
-        } else {
-            $lti_data = new LtiData();
-            $lti_data->course_id = $this->course_id;
-            $lti_data->position = LtiData::countBySQL('course_id = ?', [$this->course_id]);
+        $deployment = LtiDeployment::find($deployment_id);
+        if (!$deployment) {
+            //Redirect and do nothing:
+            $this->redirect('course/lti');
+            return;
         }
 
-        $lti_data->title = trim(Request::get('title'));
-        $lti_data->description = Studip\Markup::purifyHtml(Request::get('description'));
-        $lti_data->tool_id = Request::int('tool_id');
+        $new_position = 0;
 
-        if ($lti_data->tool_id == 0) {
-            $lti_data->launch_url = trim(Request::get('launch_url'));
-            $options['consumer_key'] = trim(Request::get('consumer_key'));
-            $options['consumer_secret'] = trim(Request::get('consumer_secret'));
-            $options['send_lis_person'] = Request::int('send_lis_person', 0);
-            $options['oauth_signature_method'] = Request::get('oauth_signature_method', 'sha1');
+        if ($direction === 'up') {
+            $new_position = $deployment->position - 1;
         } else {
-            $lti_data->launch_url = trim(Request::get('custom_url'));
+            $new_position = $deployment->position + 1;
         }
 
-        $options['custom_parameters'] = trim(Request::get('custom_parameters'));
-        $options['document_target'] = Request::option('document_target', 'window');
-        $lti_data->options = $options;
-        $lti_data->store();
+        //Find the deployment with the new position:
+        $other_deployment = LtiDeployment::findByCourseAndPosition($this->course_id, $new_position);
+        if ($other_deployment) {
+            $other_deployment->position = $deployment->position;
+            $other_deployment->store();
+        }
+        $deployment->position = $new_position;
+        $deployment->store();
 
-        PageLayout::postSuccess(_('Der Abschnitt wurde gespeichert.'));
         $this->redirect('course/lti');
     }
 
@@ -217,8 +379,8 @@ class Course_LtiController extends StudipController
     {
         CSRFProtection::verifyUnsafeRequest();
 
-        $lti_data = LtiData::findByCourseAndPosition($this->course_id, $position);
-        $lti_data->delete();
+        $deployment = LtiDeployment::findByCourseAndPosition($this->course_id, $position);
+        $deployment->delete();
 
         PageLayout::postSuccess(_('Der Abschnitt wurde gelöscht.'));
         $this->redirect('course/lti');
@@ -229,46 +391,149 @@ class Course_LtiController extends StudipController
      */
     public function add_link_action()
     {
-        $this->tools = LtiTool::findByDeep_linking(1);
+        //The permission check is done in the before filter.
+
+        $this->tools = LtiTool::findBySQL("`deep_linking` = '1' AND `range_id` = 'global' ORDER BY `name` ASC");
+        if (!$this->tools) {
+            PageLayout::postError(_('Es sind keine globalen LTI-Tools konfiguriert.'));
+            return;
+        }
     }
 
     /**
      * Dispatch a ContentItemSelectionRequest to a specified LTI tool.
      */
-    public function select_link_action()
+    public function select_link_action($deployment_id = '')
     {
-        $tool_id = Request::int('tool_id');
-        $tool = LtiTool::find($tool_id);
+        $this->deployment = null;
+        if ($deployment_id) {
+            $this->deployment = LtiDeployment::find($deployment_id);
+            if (!$this->deployment) {
+                PageLayout::postError(_('Die Einbindung des LTI-Tools wurde nicht gefunden!'));
+                return;
+            }
+            if ($this->deployment->course_id !== $this->course_id) {
+                PageLayout::postError(_('Die Einbindung des LTI-Tools ist nicht für diese Veranstaltung bestimmt.'));
+                return;
+            }
+            if (empty($this->deployment->options['unfinished_deep_linking'])) {
+                PageLayout::postError(_('Die Einbindung des LTI-Tools ist bereits abgeschlossen.'));
+                return;
+            }
+        }
 
-        $custom_parameters = explode("\n", $tool->custom_parameters);
-        $content_item_return_url = $this->url_for('course/lti/save_link/' . $tool_id);
+        $this->tool = LtiTool::find(Request::int('tool_id'));
+        if (!$this->tool) {
+            PageLayout::postError(_('Das ausgewählte LTI-Tool wurde nicht gefunden.'));
+            $this->redirect('course/lti/add_link');
+            return;
+        }
+        if (!$this->tool->deep_linking) {
+            PageLayout::postError(_('Das ausgewählte LTI-Tool unterstützt kein Deep Linking.'));
+            $this->redirect('course/lti/add_link');
+            return;
+        }
+    }
 
-        // set up ContentItemSelectionRequest
-        $lti_link = new LtiLink($tool->launch_url, $tool->consumer_key, $tool->consumer_secret, $tool->oauth_signature_method);
-        $lti_link->setUser($GLOBALS['user']->id, 'Instructor', $tool->send_lis_person);
-        $lti_link->setCourse($this->course_id);
-        $lti_link->addLaunchParameters([
-            'lti_message_type' => 'ContentItemSelectionRequest',
-            'accept_media_types' => 'application/vnd.ims.lti.v1.ltilink',
-            'accept_presentation_document_targets' => 'iframe,window',
-            'content_item_return_url' => $content_item_return_url,
-            'launch_presentation_locale' => str_replace('_', '-', $_SESSION['_language']),
-            'launch_presentation_document_target' => 'window'
-        ]);
+    public function process_select_link_action($deployment_id = '')
+    {
+        CSRFProtection::verifyUnsafeRequest();
 
-        foreach ($custom_parameters as $param) {
-            if (strpos($param, '=') !== false) {
-                list($key, $value) = explode('=', $param, 2);
-                $lti_link->addCustomParameter(trim($key), trim($value));
+        $this->deployment = null;
+        if ($deployment_id) {
+            $this->deployment = LtiDeployment::find($deployment_id);
+            if (!$this->deployment) {
+                PageLayout::postError(_('Die Einbindung des LTI-Tools wurde nicht gefunden!'));
+                return;
+            }
+            if ($this->deployment->course_id !== $this->course_id) {
+                PageLayout::postError(_('Die Einbindung des LTI-Tools ist nicht für diese Veranstaltung bestimmt.'));
+                return;
             }
+            if (empty($this->deployment->options['unfinished_deep_linking'])) {
+                PageLayout::postError(_('Die Einbindung des LTI-Tools ist bereits abgeschlossen.'));
+                return;
+            }
+        }
+
+        $this->tool = null;
+        if ($this->deployment) {
+            $this->tool = $this->deployment->tool;
+        } else {
+            $this->tool = LtiTool::find(Request::int('tool_id'));
+        }
+        if (!$this->tool) {
+            PageLayout::postError(_('Das ausgewählte LTI-Tool wurde nicht gefunden.'));
+            $this->redirect('course/lti/add_link');
+            return;
         }
+        if (!$this->tool->deep_linking) {
+            PageLayout::postError(_('Das ausgewählte LTI-Tool unterstützt kein Deep Linking.'));
+            $this->redirect('course/lti/add_link');
+            return;
+        }
+
+        if ($this->tool->lti_version === '1.3a') {
+            //LTI 1.3a
+            if ($this->deployment) {
+                $builder = new DeepLinkingLaunchRequestBuilder();
+                $message = $builder->buildDeepLinkingLaunchRequest(
+                    PlatformManager::getDeepLinkingConfiguration($this->tool->id),
+                    new Registration($this->deployment->tool),
+                    $GLOBALS['user']->id,
+                    null,
+                    $this->deployment->id,
+                    [PlatformManager::getLtiRoleClaimForStudipRole($GLOBALS['perm']->get_studip_perm($this->course_id))]
+                );
+                $this->render_text($message->toHtmlRedirectForm());
+            } else {
+                //Build an LTI deployment object and mark it as not configured
+                //so that it can be displayed differently in the UI.
+                $this->deployment = new LtiDeployment();
+                $this->deployment->tool_id = $this->tool->id;
+                $this->deployment->course_id = $this->course_id;
+                $this->deployment->title = $this->tool->name;
+                $this->deployment->options = ['unfinished_deep_linking' => true];
+                if ($this->deployment->store() !== false) {
+                    //Display the tool deployment data so that the user can enter
+                    //them in the LTI tool.
+                    PageLayout::postInfo(
+                        _('Bitte tragen Sie die Daten zur Einbindung im LTI-Tool ein bevor sie fortfahren.')
+                    );
+                }
+            }
+        } else {
+            //LTI 1.0/1.1
+            $custom_parameters = explode("\n", $this->tool->custom_parameters);
+            $content_item_return_url = $this->url_for('course/lti/save_link/' . $this->tool->id);
+
+            // set up ContentItemSelectionRequest
+            $lti_link = new LtiLink($this->tool->launch_url, $this->tool->consumer_key, $this->tool->consumer_secret, $this->tool->oauth_signature_method);
+            $lti_link->setUser($GLOBALS['user']->id, 'Instructor', $this->tool->send_lis_person);
+            $lti_link->setCourse($this->course_id);
+            $lti_link->addLaunchParameters([
+                'lti_message_type' => 'ContentItemSelectionRequest',
+                'accept_media_types' => 'application/vnd.ims.lti.v1.ltilink',
+                'accept_presentation_document_targets' => 'iframe,window',
+                'content_item_return_url' => $content_item_return_url,
+                'launch_presentation_locale' => str_replace('_', '-', $_SESSION['_language']),
+                'launch_presentation_document_target' => 'window'
+            ]);
+
+            foreach ($custom_parameters as $param) {
+                if (strpos($param, '=') !== false) {
+                    list($key, $value) = explode('=', $param, 2);
+                    $lti_link->addCustomParameter(trim($key), trim($value));
+                }
+            }
 
-        $this->launch_url = $lti_link->getLaunchURL();
-        $this->launch_data = $lti_link->getBasicLaunchData();
-        $this->signature = $lti_link->getLaunchSignature($this->launch_data);
+            $this->launch_url = $lti_link->getLaunchURL();
+            $this->launch_data = $lti_link->getBasicLaunchData();
+            $this->signature = $lti_link->getLaunchSignature($this->launch_data);
 
-        $this->set_layout(null);
-        $this->render_action('iframe');
+            $this->set_layout(null);
+            $this->render_action('iframe');
+        }
     }
 
     /**
@@ -279,43 +544,103 @@ class Course_LtiController extends StudipController
     public function save_link_action($tool_id)
     {
         $tool = LtiTool::find($tool_id);
-        $lti_msg = Request::get('lti_msg');
-        $lti_errormsg = Request::get('lti_errormsg');
-        $content_items = Request::get('content_items');
-        $content_items = json_decode($content_items, true);
 
-        if (!Studip\OAuth1::verifyRequest($this->getPsrRequest(), $tool->consumer_secret, '')) {
-            throw new Exception('Could not verify request.');
+        if (!$tool) {
+            PageLayout::postError(_('Das ausgewählte LTI-Tool wurde nicht gefunden.'));
+            $this->redirect('course/lti/add_link');
+            return;
+        }
+        if (!$tool->deep_linking) {
+            PageLayout::postError(_('Das ausgewählte LTI-Tool unterstützt kein Deep Linking.'));
+            $this->redirect('course/lti/add_link');
+            return;
         }
 
-        if (is_array($content_items) && count($content_items['@graph'])) {
-            // we only support selecting a single content item
-            $item = $content_items['@graph'][0];
-
-            $lti_data = new LtiData();
-            $lti_data->course_id = $this->course_id;
-            $lti_data->position = LtiData::countBySQL('course_id = ?', [$this->course_id]);
-            $lti_data->title = (string) $item['title'];
-            $lti_data->description = Studip\Markup::purifyHtml(Studip\Markup::markAsHtml($item['text']));
-            $lti_data->tool_id = $tool_id;
-            $lti_data->launch_url = (string) ($item['url'] ?? '');
-            $options = [];
-            if (is_array($item['custom'])) {
-                $custom_parameters = '';
-                foreach ($item['custom'] as $key => $value) {
-                    $custom_parameters .= $key . '=' . $value . "\n";
-                }
-                $options['custom_parameters'] = $custom_parameters;
+        if ($tool->lti_version === '1.3a') {
+            //LTI 1.3a
+
+            $validator = new PlatformLaunchValidator(
+                new RegistrationManager(),
+                new NonceRepository(Studip\Cache\Factory::getCache())
+            );
+            $result = $validator->validateToolOriginatingLaunch($this->getPsrRequest());
+            if ($result->hasError()) {
+                PageLayout::postError($result->getError());
+                $this->redirect('course/lti/add_link');
+                return;
             }
+            $all_lti_resources = (new ResourceCollectionFactory())->createFromClaim(
+                $result->getPayload()->getDeepLinkingContentItems()
+            );
 
-            if (isset($item['placementAdvice']['presentationDocumentTarget'])) {
-                $options['document_target'] = $item['placementAdvice']['presentationDocumentTarget'];
+            $lti_resource_links = $all_lti_resources->getByType(LtiResourceLinkInterface::TYPE);
+            if (count($lti_resource_links) > 0) {
+                $use_first_link = true;
+                foreach ($lti_resource_links as $lti_resource_link) {
+                    $deployment = null;
+                    if ($use_first_link) {
+                        //Recycle the deployment that has been created before
+                        //for the course.
+                        $deployment = LtiDeployment::findOneBySQL(
+                            "`tool_id` = :tool_id AND `course_id` = :course_id
+                            AND `options` LIKE '%unfinished_deep_linking%=%true'"
+                        );
+                        $use_first_link = false;
+                    }
+                    if (!$deployment) {
+                        //If this is the first link, the deployment has been removed.
+                        //In that case and if it is not the first link, a new deployment
+                        //has to be created.
+                        $deployment = new LtiDeployment();
+                        $deployment->tool_id   = $tool->id;
+                        $deployment->title     = $tool->name;
+                        $deployment->course_id = $this->course_id;
+                    }
+                    $deployment->launch_url = $lti_resource_link->getUrl();
+                    if (!empty($deployment->options['unfinished_deep_linking'])) {
+                        unset($deployment->options['unfinished_deep_linking']);
+                    }
+                    $deployment->store();
+                }
             }
+        } else {
+            $lti_msg = Request::get('lti_msg');
+            $lti_errormsg = Request::get('lti_errormsg');
+            $content_items = Request::get('content_items');
+            $content_items = json_decode($content_items, true);
+
+        if (!Studip\OAuth1::verifyRequest($this->getPsrRequest(), $tool->consumer_secret, '')) {
+            throw new Exception('Could not verify request.');
+        }
 
-            $lti_data->options = $options;
-            $lti_data->store();
+            if (is_array($content_items) && count($content_items['@graph'])) {
+                // we only support selecting a single content item
+                $item = $content_items['@graph'][0];
+
+                $lti_data = new LtiDeployment();
+                $lti_data->course_id = $this->course_id;
+                $lti_data->position = LtiDeployment::countBySQL('course_id = ?', [$this->course_id]);
+                $lti_data->title = (string) $item['title'];
+                $lti_data->description = Studip\Markup::purifyHtml(Studip\Markup::markAsHtml($item['text']));
+                $lti_data->tool_id = $tool_id;
+                $lti_data->launch_url = (string) ($item['url'] ?? '');
+                $options = [];
+                if (is_array($item['custom'])) {
+                    $custom_parameters = '';
+                    foreach ($item['custom'] as $key => $value) {
+                        $custom_parameters .= $key . '=' . $value . "\n";
+                    }
+                    $options['custom_parameters'] = $custom_parameters;
+                }
+
+                if (isset($item['placementAdvice']['presentationDocumentTarget'])) {
+                    $options['document_target'] = $item['placementAdvice']['presentationDocumentTarget'];
+                }
 
-            PageLayout::postSuccess($lti_msg ?: _('Der Link wurde als neuer Abschnitt hinzugefügt.'));
+                $lti_data->options = $options;
+                $lti_data->store();
+                PageLayout::postSuccess($lti_msg ?: _('Der Link wurde als neuer Abschnitt hinzugefügt.'));
+            }
         }
 
         if ($lti_errormsg) {
@@ -328,7 +653,7 @@ class Course_LtiController extends StudipController
     /**
      * Return an LtiLink object for the configured LTI content block.
      *
-     * @param   LtiData $lti_data data of LTI content block
+     * @param   LtiDeployment $lti_data data of LTI content block
      *
      * @return  LtiLink  LTI link representation
      */
@@ -455,7 +780,7 @@ class Course_LtiController extends StudipController
      */
     public function outcome_action($id)
     {
-        $lti_data = LtiData::find($id);
+        $lti_data = LtiDeployment::find($id);
 
         if (!Studip\OAuth1::verifyRequest($this->getPsrRequest(), $lti_data->getConsumerSecret(), '')) {
             throw new Exception('Could not verify request.');
@@ -509,13 +834,22 @@ class Course_LtiController extends StudipController
     }
 
     /**
-     * Display the (simple) LTI gradebook.
+     * Display the (simple) LTI grade book.
      */
     public function grades_action()
     {
         Navigation::activateItem('/course/lti/grades');
 
-        $this->lti_data_array = LtiData::findByCourse_id($this->course_id, 'ORDER BY position');
+        if ($this->edit_perm) {
+            $this->lti_data_array = LtiDeployment::findByCourse_id($this->course_id, 'ORDER BY position');
+        } else {
+            //Only load those deployments that are fully configured:
+            $this->lti_data_array = LtiDeployment::findBySQL(
+                "`course_id` = :course_id AND (`options` IS NULL OR `options` NOT LIKE '%unfinished_deep_linking%')
+                ORDER BY `position`",
+                ['course_id' => $this->course_id]
+            );
+        }
 
         if ($this->edit_perm) {
             $this->desc = Request::int('desc');
@@ -543,7 +877,16 @@ class Course_LtiController extends StudipController
      */
     public function export_grades_action()
     {
-        $lti_data_array = LtiData::findByCourse_id($this->course_id, 'ORDER BY position');
+        if ($this->edit_perm) {
+            $lti_data_array = LtiDeployment::findByCourse_id($this->course_id, 'ORDER BY position');
+        } else {
+            //Only load those deployments that are fully configured:
+            $lti_data_array = LtiDeployment::findBySQL(
+                "`course_id` = :course_id AND (`options` IS NULL OR `options` NOT LIKE '%unfinished_deep_linking%')
+                ORDER BY `position`",
+                ['course_id' => $this->course_id]
+            );
+        }
 
         $columns = [_('Nachname'), _('Vorname')];
 
diff --git a/app/controllers/lti.php b/app/controllers/lti.php
deleted file mode 100644
index 82d9840fb81..00000000000
--- a/app/controllers/lti.php
+++ /dev/null
@@ -1,127 +0,0 @@
-<?php
-/**
- * lti.php - LTI 1.1 single sign on controller
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU General Public License as
- * published by the Free Software Foundation; either version 2 of
- * the License, or (at your option) any later version.
- *
- * @author      Elmar Ludwig
- * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
- * @category    Stud.IP
- */
-
-class LtiController extends AuthenticatedController
-{
-    /**
-     * Callback function being called before an action is executed.
-     */
-    public function before_filter(&$action, &$args)
-    {
-        // enforce LTI SSO login
-        Request::set('sso', 'lti');
-
-        parent::before_filter($action, $args);
-    }
-
-    /**
-     * Redirect to enrollment action for the given course, if needed.
-     */
-    public function index_action($course_id = null)
-    {
-        $course_id = Request::option('custom_cid', $course_id);
-        $course_id = Request::option('custom_course', $course_id);
-        $message_type = Request::option('lti_message_type');
-
-        if ($message_type === 'ContentItemSelectionRequest') {
-            $_SESSION['ContentItemSelection'] = [
-                'oauth_consumer_key' => Request::get('oauth_consumer_key'),
-                'content_item_return_url' => Request::get('content_item_return_url'),
-                'document_targets' => Request::get('accept_presentation_document_targets'),
-                'data' => Request::get('data')
-            ];
-            $this->redirect('lti/content_item');
-        } else if ($course_id) {
-            $this->redirect('course/enrolment/apply/' . $course_id);
-        } else {
-            $this->redirect('start');
-        }
-    }
-
-    /**
-     * Select course for ContentItemSelectionRequest message.
-     */
-    public function content_item_action()
-    {
-        PageLayout::setTitle(_('Veranstaltung verknüpfen'));
-        Navigation::activateItem('/browse/my_courses/content_item');
-
-        $this->document_targets = $_SESSION['ContentItemSelection']['document_targets'];
-        $this->target_labels = [
-            'embed'   => _('in Seite einbetten'),
-            'frame'   => _('gleiches Fenster oder Tab'),
-            'iframe'  => _('IFrame in der Seite'),
-            'window'  => _('neues Fenster oder Tab'),
-            'popup'   => _('Popup-Fenster'),
-            'overlay' => _('Dialog'),
-            'none'    => _('nicht anzeigen')
-        ];
-
-        $sql = "JOIN seminar_user USING(Seminar_id)
-                LEFT JOIN semester_courses sc ON seminare.seminar_id = sc.course_id
-                LEFT JOIN semester_data s USING (semester_id)
-                WHERE user_id = ? AND seminar_user.status IN ('dozent', 'tutor')
-                ORDER BY s.beginn DESC, Name";
-        $this->courses = Course::findBySQL($sql, [$GLOBALS['user']->id]);
-    }
-
-    /**
-     * Return the selected content item to the LTI consumer.
-     */
-    public function link_content_item_action()
-    {
-        CSRFProtection::verifyUnsafeRequest();
-        $course_id = Request::option('course_id');
-        $target = Request::option('target');
-        $course = Course::find($course_id);
-
-        $consumer_key = $_SESSION['ContentItemSelection']['oauth_consumer_key'];
-        $return_url = $_SESSION['ContentItemSelection']['content_item_return_url'];
-        $data = $_SESSION['ContentItemSelection']['data'];
-        unset($_SESSION['ContentItemSelection']);
-
-        $consumer_config = $GLOBALS['STUDIP_AUTH_CONFIG_LTI']['consumer_keys'][$consumer_key];
-        $consumer_secret = $consumer_config['consumer_secret'];
-        $signature_method = $consumer_config['signature_method'] ?? 'sha1';
-
-        $content_items = [
-            '@context' => 'http://purl.imsglobal.org/ctx/lti/v1/ContentItem',
-            '@graph' => []
-        ];
-
-        if (Request::submitted('link')) {
-            $content_items['@graph'][] = [
-                '@type' => 'LtiLinkItem',
-                'mediaType' => 'application/vnd.ims.lti.v1.ltilink',
-                'title' => $course->name,
-                'text' => $course->beschreibung,
-                'placementAdvice' => ['presentationDocumentTarget' => $target],
-                'custom' => ['course' => $course_id]
-            ];
-        }
-
-        // set up ContentItemSelection
-        $lti_link = new LtiLink($return_url, $consumer_key, $consumer_secret, $signature_method);
-        $lti_link->addLaunchParameters([
-            'lti_message_type' => 'ContentItemSelection',
-            'content_items' => json_encode($content_items),
-            'data' => $data
-        ]);
-
-        $this->launch_url = $lti_link->getLaunchURL();
-        $this->launch_data = $lti_link->getBasicLaunchData();
-        $this->signature = $lti_link->getLaunchSignature($this->launch_data);
-        $this->render_template('course/lti/iframe');
-    }
-}
diff --git a/app/controllers/lti/ags.php b/app/controllers/lti/ags.php
new file mode 100644
index 00000000000..be4aed71607
--- /dev/null
+++ b/app/controllers/lti/ags.php
@@ -0,0 +1,106 @@
+<?php
+
+use Studip\LTI13a\LineItemRepository;
+use Studip\LTI13a\RegistrationManager;
+use OAT\Library\Lti1p3Core\Security\OAuth2\Validator\RequestAccessTokenValidator;
+use OAT\Library\Lti1p3Ags\Service\LineItem\Server\Handler\UpdateLineItemServiceServerRequestHandler;
+use OAT\Library\Lti1p3Ags\Service\LineItem\Server\Handler\DeleteLineItemServiceServerRequestHandler;
+use OAT\Library\Lti1p3Ags\Service\LineItem\Server\Handler\GetLineItemServiceServerRequestHandler;
+use OAT\Library\Lti1p3Ags\Service\Result\Server\Handler\ResultServiceServerRequestHandler;
+use OAT\Library\Lti1p3Ags\Service\Score\Server\Handler\ScoreServiceServerRequestHandler;
+use OAT\Library\Lti1p3Ags\Service\LineItem\Server\Handler\CreateLineItemServiceServerRequestHandler;
+use OAT\Library\Lti1p3Ags\Service\LineItem\Server\Handler\ListLineItemsServiceServerRequestHandler;
+use OAT\Library\Lti1p3Core\Service\Server\LtiServiceServer;
+
+/**
+ * ags.php - LTI assignment and grade services controller
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ *
+ * @author      Moritz Strohm
+ * @date        2024
+ * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
+ * @category    Stud.IP
+ */
+
+
+class Lti_AgsController extends StudipController
+{
+    use \Studip\OAuth2\NegotiatesWithPsr7;
+
+    public function __construct(\Trails\Dispatcher $dispatcher)
+    {
+        $this->with_session = true;
+        parent::__construct($dispatcher);
+    }
+
+    public function before_filter(&$action, &$args)
+    {
+        parent::before_filter($action, $args);
+
+        //All the work is done by the OAT-SA library and
+        //the implementation of its interfaces in Stud.IP.
+        //Only the handler changes for the endpoints.
+        $reg_manager = new RegistrationManager();
+        $line_item_repo = new LineItemRepository();
+        $validator = new RequestAccessTokenValidator($reg_manager);
+        $handler = null;
+        if ($action === 'line_item') {
+            if (empty($args)) {
+                if (Request::isPut()) {
+                    //Update a line item:
+                    $handler = new UpdateLineItemServiceServerRequestHandler($line_item_repo);
+                } elseif (Request::isDelete()) {
+                    //Delete a line item:
+                    $handler = new DeleteLineItemServiceServerRequestHandler($line_item_repo);
+                } else {
+                    //Get a line item:
+                    $handler = new GetLineItemServiceServerRequestHandler($line_item_repo);
+                }
+            } elseif ($args[0] === 'results') {
+                $handler = new ResultServiceServerRequestHandler($line_item_repo, new Studip\LTI13a\ResultRepository());
+            } elseif ($args[0] === 'scores') {
+                $handler = new ScoreServiceServerRequestHandler($line_item_repo,new \Studip\LTI13a\ScoreRepository());
+            }
+        } elseif ($action === 'line_items') {
+            if (Request::isPost()) {
+                //Create a line item:
+                $handler = new CreateLineItemServiceServerRequestHandler($line_item_repo);
+            } else {
+                //List line items:
+                $handler = new ListLineItemsServiceServerRequestHandler($line_item_repo);
+            }
+        } else {
+            //Invalid endpoint.
+            throw new AccessDeniedException(studip_interpolate('Invalid endpoint: %{endpoint}', ['endpoint' => $action]));
+        }
+        if (!$handler) {
+            throw new \Studip\LTIException('No handler available for this request.');
+        }
+        $server = new LtiServiceServer($validator, $handler);
+        $this->renderPsrResponse($server->handle($this->getPsrRequest()));
+    }
+
+    /**
+     * This is the endpoint for the LTI AGS lineitem service.
+     *
+     * @return void
+     */
+    public function line_item_action(): void
+    {
+        //Nothing here. All is done in the before_filter.
+    }
+
+    /**
+     * This is the endpoint for the LTI AGS lineitems service.
+     *
+     * @return void
+     */
+    public function line_items_action(): void
+    {
+        //Nothing here. All is done in the before_filter.
+    }
+}
diff --git a/app/controllers/lti/auth.php b/app/controllers/lti/auth.php
new file mode 100644
index 00000000000..07b6096647b
--- /dev/null
+++ b/app/controllers/lti/auth.php
@@ -0,0 +1,250 @@
+<?php
+
+use OAT\Library\Lti1p3Core\Message\Payload\Builder\MessagePayloadBuilder;
+use OAT\Library\Lti1p3Core\Security\Jwks\Exporter\JwksExporter;
+use OAT\Library\Lti1p3Core\Security\Jwks\Server\JwksRequestHandler;
+use OAT\Library\Lti1p3Core\Security\Key\KeyChainRepository;
+use OAT\Library\Lti1p3Core\Security\OAuth2\Factory\AuthorizationServerFactory;
+use OAT\Library\Lti1p3Core\Security\OAuth2\Generator\AccessTokenResponseGenerator;
+use OAT\Library\Lti1p3Core\Security\OAuth2\Repository\AccessTokenRepository;
+use OAT\Library\Lti1p3Core\Security\OAuth2\Repository\ClientRepository;
+use OAT\Library\Lti1p3Core\Security\OAuth2\Repository\ScopeRepository;
+use OAT\Library\Lti1p3Core\Security\Oidc\OidcAuthenticator;
+use OAT\Library\Lti1p3Core\Security\Oidc\Server\OidcAuthenticationRequestHandler;
+use Studip\Cache\Factory;
+use Studip\LTI13a\KeyManager;
+use Studip\LTI13a\NonceGenerator;
+use Studip\LTI13a\PlatformManager;
+use Studip\LTI13a\RegistrationManager;
+use Studip\LTI13a\UserAuthenticator;
+use Studip\OAuth2\Bridge\ScopeEntity;
+
+/**
+ * auth.php - LTI authentication controller
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ *
+ * @author      Elmar Ludwig
+ * @author      Moritz Strohm
+ * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
+ * @category    Stud.IP
+ */
+
+class Lti_AuthController extends StudipController
+{
+    use Studip\OAuth2\NegotiatesWithPsr7;
+
+    public function __construct(\Trails\Dispatcher $dispatcher)
+    {
+        $this->allow_nobody = false;
+        $action = basename(get_route());
+        if (in_array($action, ['jwks', 'oauth2_token'])) {
+            $this->allow_nobody = true;
+            $this->with_session = $action !== 'jwks';
+        }
+        parent::__construct($dispatcher);
+    }
+
+    /**
+     * Callback function being called before an action is executed.
+     */
+    public function before_filter(&$action, &$args)
+    {
+        if (in_array($action, ['index', 'content_item', 'link_content_item'])) {
+            // enforce LTI SSO login
+            Request::set('sso', 'lti');
+        }
+        parent::before_filter($action, $args);
+    }
+
+    /**
+     * Redirect to enrolment action for the given course, if needed.
+     */
+    public function index_action($course_id = null)
+    {
+        $course_id = Request::option('custom_cid', $course_id);
+        $course_id = Request::option('custom_course', $course_id);
+        $message_type = Request::option('lti_message_type');
+
+        if ($message_type === 'ContentItemSelectionRequest') {
+            $_SESSION['ContentItemSelection'] = [
+                'oauth_consumer_key' => Request::get('oauth_consumer_key'),
+                'content_item_return_url' => Request::get('content_item_return_url'),
+                'document_targets' => Request::get('accept_presentation_document_targets'),
+                'data' => Request::get('data')
+            ];
+            $this->redirect('lti/content_item');
+        } else if ($course_id) {
+            $this->redirect('course/enrolment/apply/' . $course_id);
+        } else {
+            $this->redirect('start');
+        }
+    }
+
+    /**
+     * Select course for ContentItemSelectionRequest message.
+     */
+    public function content_item_action()
+    {
+        PageLayout::setTitle(_('Veranstaltung verknüpfen'));
+        Navigation::activateItem('/browse/my_courses/content_item');
+
+        $this->document_targets = $_SESSION['ContentItemSelection']['document_targets'];
+        $this->target_labels = [
+            'embed'   => _('in Seite einbetten'),
+            'frame'   => _('gleiches Fenster oder Tab'),
+            'iframe'  => _('IFrame in der Seite'),
+            'window'  => _('neues Fenster oder Tab'),
+            'popup'   => _('Popup-Fenster'),
+            'overlay' => _('Dialog'),
+            'none'    => _('nicht anzeigen')
+        ];
+
+        $sql = "JOIN seminar_user USING(Seminar_id)
+                LEFT JOIN semester_courses sc ON seminare.seminar_id = sc.course_id
+                LEFT JOIN semester_data s USING (semester_id)
+                WHERE user_id = ? AND seminar_user.status IN ('dozent', 'tutor')
+                ORDER BY s.beginn DESC, Name";
+        $this->courses = Course::findBySQL($sql, [$GLOBALS['user']->id]);
+    }
+
+    /**
+     * Return the selected content item to the LTI consumer.
+     */
+    public function link_content_item_action()
+    {
+        CSRFProtection::verifyUnsafeRequest();
+        $course_id = Request::option('course_id');
+        $target = Request::option('target');
+        $course = Course::find($course_id);
+
+        $consumer_key = $_SESSION['ContentItemSelection']['oauth_consumer_key'];
+        $return_url = $_SESSION['ContentItemSelection']['content_item_return_url'];
+        $data = $_SESSION['ContentItemSelection']['data'];
+        unset($_SESSION['ContentItemSelection']);
+
+        $consumer_config = $GLOBALS['STUDIP_AUTH_CONFIG_LTI']['consumer_keys'][$consumer_key];
+        $consumer_secret = $consumer_config['consumer_secret'];
+        $signature_method = $consumer_config['signature_method'] ?? 'sha1';
+
+        $content_items = [
+            '@context' => 'http://purl.imsglobal.org/ctx/lti/v1/ContentItem',
+            '@graph' => []
+        ];
+
+        if (Request::submitted('link')) {
+            $content_items['@graph'][] = [
+                '@type' => 'LtiLinkItem',
+                'mediaType' => 'application/vnd.ims.lti.v1.ltilink',
+                'title' => $course->name,
+                'text' => $course->beschreibung,
+                'placementAdvice' => ['presentationDocumentTarget' => $target],
+                'custom' => ['course' => $course_id]
+            ];
+        }
+
+        // set up ContentItemSelection
+        $lti_link = new LtiLink($return_url, $consumer_key, $consumer_secret, $signature_method);
+        $lti_link->addLaunchParameters([
+            'lti_message_type' => 'ContentItemSelection',
+            'content_items' => json_encode($content_items),
+            'data' => $data
+        ]);
+
+        $this->launch_url = $lti_link->getLaunchURL();
+        $this->launch_data = $lti_link->getBasicLaunchData();
+        $this->signature = $lti_link->getLaunchSignature($this->launch_data);
+        $this->render_template('course/lti/iframe');
+    }
+
+    /**
+     * This action handles OIDC (OpenID connect) requests.
+     *
+     * @return void
+     */
+    public function oidc_init_action(): void
+    {
+        $reg_manager = new RegistrationManager();
+        $user_authenticator = new UserAuthenticator();
+        $request = $this->getPsrRequest();
+
+        $oidc_handler = new OidcAuthenticationRequestHandler(
+            new OidcAuthenticator(
+                $reg_manager,
+                $user_authenticator,
+                //The following is necessary due to a library bug.
+                //See: https://github.com/oat-sa/lib-lti1p3-core/issues/154
+                new MessagePayloadBuilder(new NonceGenerator(true))
+            )
+        );
+        $response = $oidc_handler->handle($request);
+        $this->renderPsrResponse($response);
+    }
+
+    /**
+     * This action handles JSON web key set (JWKS) requests for the platform key.
+     *
+     * @return void
+     */
+    public function jwks_action(): void
+    {
+        $repo = new KeyChainRepository();
+        $keyring = Keyring::findOneBySQL("`range_type` = 'global' AND `range_id` = 'lti13a_platform'");
+        if ($keyring) {
+            $repo->addKeyChain($keyring->toKeyChain());
+        }
+        $handler = new JwksRequestHandler(new JwksExporter($repo));
+        $response = $handler->handle('lti13a_platform');
+        $this->renderPsrResponse($response);
+    }
+
+    /**
+     * Generates OAuth2 tokens for LTI tools.
+     */
+    public function oauth2_token_action(): void
+    {
+        $keyring = Keyring::findOneByRange_id('lti13a_platform');
+        if (!$keyring) {
+            throw new \Studip\Exception(
+                'Stud.IP LTI 1.3a platform keyring cannot be found!'
+            );
+        }
+        $key_chain = $keyring->toKeyChain();
+        $response_generator = new AccessTokenResponseGenerator(
+            new KeyManager(),
+            new AuthorizationServerFactory(
+                new ClientRepository(new RegistrationManager()),
+                new AccessTokenRepository(Factory::getCache()),
+                new ScopeRepository(
+                    [
+                        new ScopeEntity('https://purl.imsglobal.org/spec/lti-ags/scope/lineitem'),
+                        new ScopeEntity('https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly'),
+                        new ScopeEntity('https://purl.imsglobal.org/spec/lti-ags/scope/score')
+                    ]
+                ),
+                $key_chain->getPrivateKey()->getContent()
+            )
+        );
+
+        $response = $response_generator->generate(
+            $this->getPsrRequest(),
+            $this->getPsrResponse(),
+            'lti13a_platform'
+        );
+
+        $this->renderPsrResponse($response);
+    }
+
+    /**
+     * Displays LTI platform data of the Stud.IP installation. The data are needed for configuring the
+     * platform on the tool side.
+     */
+    public function platform_data_action()
+    {
+        $this->platform = PlatformManager::getPlatformConfiguration();
+        $this->render_template('lti/_platform_data');
+    }
+}
diff --git a/app/controllers/lti/tool.php b/app/controllers/lti/tool.php
new file mode 100644
index 00000000000..0e7077e9b09
--- /dev/null
+++ b/app/controllers/lti/tool.php
@@ -0,0 +1,236 @@
+<?php
+
+class Lti_ToolController extends AuthenticatedController
+{
+    public function before_filter(&$action, &$args)
+    {
+        parent::before_filter($action, $args);
+
+        $this->tool       = null;
+        $this->deployment = null;
+        $this->tool_id    = '';
+        $this->range_id   = '';
+
+        if (in_array($action, ['index', 'add', 'edit', 'delete'])) {
+            $this->range_id = $args[0];
+            $this->tool_id  = $args[1] ?? '';
+
+            if ($action === 'add' && !$this->tool_id) {
+                $this->tool = new LtiTool();
+                $this->tool->range_id = $this->range_id;
+            } else {
+                if (!$this->tool_id) {
+                    PageLayout::postError(_('Es wurde kein LTI-Tool angegeben.'));
+                    return;
+                }
+                $this->tool = LtiTool::find($this->tool_id);
+                if (!$this->tool) {
+                    throw new \Studip\Exception(_('Das angegebene LTI-Tool wurde nicht gefunden.'));
+                }
+            }
+        }
+    }
+
+    public function index_action($range_id, $tool_id): void
+    {
+        //$this->tool is created in the before-filter.
+        if ($this->range_id !== 'global') {
+            $this->deployment = LtiDeployment::findOneBySQL(
+                '`tool_id` = :tool_id AND `course_id` = :range_id',
+                ['tool_id' => $this->tool->id, 'range_id' => $this->range_id]
+            );
+        }
+    }
+
+    public function add_action($range_id, $tool_id = ''): void
+    {
+        //NOTE: The parameters are checked and processed in the before_filter.
+        $this->addEditHandler();
+    }
+
+    public function edit_action($range_id, $tool_id): void
+    {
+        //NOTE: The parameters are checked and processed in the before_filter.
+        $this->addEditHandler();
+    }
+
+    protected function addEditHandler(): void
+    {
+        if (!$this->tool) {
+            return;
+        }
+        $this->deployment = null;
+        if ($this->tool->isNew()) {
+            if (!Config::get()->LTI_ALLOW_TOOL_CONFIG_IN_COURSE && $this->range_id !== 'global') {
+                throw new AccessDeniedException(
+                    _('Die Einrichtung von LTI-Tools in Veranstaltungen ist ausgeschaltet.')
+                );
+            }
+            if ($this->tool->range_id === 'global' && !$this->tool->isEditableByUser()) {
+                throw new AccessDeniedException();
+            }
+            PageLayout::postWarning(_('Bitte beachten Sie das geltende europäische Datenschutzrecht (DSGVO)!'));
+            if ($this->tool->range_id !== 'global') {
+                $this->deployment = new LtiDeployment();
+                $this->deployment->course_id = $this->tool->range_id;
+            }
+        } elseif ($this->range_id !== 'global') {
+            $this->deployment = LtiDeployment::findOneBySQL(
+                '`tool_id` = :tool_id AND `course_id` = :range_id',
+                ['tool_id' => $this->tool->id, 'range_id' => $this->range_id]
+            );
+            if (!$this->deployment) {
+                //Create a new deployment:
+                $this->deployment = new LtiDeployment();
+                $this->deployment->tool_id = $this->tool->id;
+                $this->deployment->course_id = $this->range_id;
+            }
+        }
+
+        if (Request::isPost()) {
+            $this->saveTool();
+        }
+    }
+
+    /**
+     * Handles the saving of a tool.
+     */
+    protected function saveTool(): void
+    {
+        CSRFProtection::verifyUnsafeRequest();
+        if ($this->range_id === 'global') {
+            //The admin page for editing global tools.
+            $this->tool->name = trim(Request::get('name'));
+            $this->tool->launch_url        = trim(Request::get('launch_url'));
+        } else {
+            //The page for editing tools configured in courses.
+            $this->deployment->title       = trim(Request::get('name'));
+            $this->deployment->description = trim(Request::get('description'));
+            $this->deployment->launch_url  = trim(Request::get('launch_url'));
+            $document_target = trim(Request::get('document_target'));
+            if ($document_target === 'iframe') {
+                if (!is_array($this->deployment->options)) {
+                    $this->deployment->options = [];
+                }
+                $this->deployment->options['document_target'] = $document_target;
+            } elseif (isset($this->deployment->options['document_target'])) {
+                unset($this->deployment->options['document_target']);
+            }
+        }
+
+        //If a deployment is present, the tool is not used in the global context.
+        //If a tool is not used in the global context and the range_id is not set to "global",
+        //it is a tool that is only used for one course.
+        if (
+            !$this->deployment
+            || $this->tool->range_id !== 'global'
+            || $GLOBALS['perm']->have_perm('root')
+        ) {
+            $this->tool->name                  = trim(Request::get('name'));
+            $this->tool->launch_url            = trim(Request::get('launch_url'));
+            $this->tool->terms_of_use_url      = trim(Request::get('terms_of_use_url'));
+            $this->tool->privacy_policy_url    = trim(Request::get('privacy_policy_url'));
+            $this->tool->data_protection_notes = trim(Request::get('data_protection_notes'));
+            $this->tool->lti_version           = Request::get('lti_version', '1.3a');
+            if ($this->tool->lti_version === '1.3a') {
+                $this->tool->oauth_signature_method = 'sha256';
+                $this->tool->oidc_init_url    = trim(Request::get('oidc_init_url'));
+                $this->tool->jwks_url         = trim(Request::get('jwks_url'));
+                $this->tool->jwks_key_id      = trim(Request::get('jwks_key_id'));
+                $this->tool->deep_linking_url = trim(Request::get('deep_linking_url'));
+                $this->tool->deep_linking = (bool) $this->tool->deep_linking_url;
+            } else {
+                //LTI 1.0/1.1:
+                $this->tool->oauth_signature_method = 'sha1';
+                $this->tool->consumer_key           = trim(Request::get('consumer_key'));
+                $this->tool->consumer_secret        = trim(Request::get('consumer_secret'));
+            }
+            $this->tool->send_lis_person   = Request::int('send_lis_person', 0);
+            $this->tool->custom_parameters = trim(Request::get('custom_parameters'));
+            $tool_public_key = trim(Request::get('tool_public_key'));
+            $errors = $this->tool->validate();
+            if ($errors) {
+                PageLayout::postError(
+                    _('Die folgenden Daten zum LTI-Tool sind fehlerhaft:'),
+                    array_map('htmlReady', $errors)
+                );
+                return;
+            }
+            if ($this->tool->lti_version === '1.3a' && !$tool_public_key && !$this->tool->jwks_url) {
+                PageLayout::postError(
+                    _('Es wurde weder ein öffentlicher Schlüssel noch eine JWKS-URL zum Tool angegeben.')
+                );
+                return;
+            }
+            if ($this->tool->store() !== false) {
+                if ($this->deployment) {
+                    $this->deployment->tool_id = $this->tool->id;
+                }
+            } else {
+                PageLayout::postError(_('Das LTI-Tool konnte nicht gespeichert werden.'));
+                return;
+            }
+        }
+        if ($this->deployment) {
+            $this->deployment->store();
+        }
+        if ($this->tool->lti_version === '1.3a' && $tool_public_key) {
+            if (!$this->tool->updatePublicKey($tool_public_key)) {
+                PageLayout::postError(
+                    _('Der öffentliche Schlüssel des LTI-Tools konnte nicht gespeichert werden.')
+                );
+            }
+        }
+
+        PageLayout::postSuccess(_('Das LTI-Tool wurde gespeichert.'));
+        if (Request::isDialog()) {
+            $this->response->add_header('X-Dialog-Close', '1');
+            $this->render_nothing();
+        } elseif ($this->range_id === 'global') {
+            $this->redirect('admin/lti');
+        } else {
+            $this->redirect('course/lti');
+        }
+    }
+
+    public function delete_action($range_id, $tool_id): void
+    {
+        //NOTE: The parameters are checked and processed in the before_filter.
+        CSRFProtection::verifyUnsafeRequest();
+        $deleted = false;
+        $tool_name = $this->tool->name;
+        if ($this->tool->range_id === 'global') {
+            if ($range_id === 'global') {
+                $deleted = $this->tool->delete();
+            } else {
+                //A tool shall be deleted from a course: Delete the deployment instead.
+                $deployment = LtiDeployment::findOneBySQL(
+                    "`tool_id` = :tool_id AND `course_id` = :course_id",
+                    ['tool_id' => $this->tool->id, 'course_id' => $range_id]
+                );
+                if ($deployment) {
+                    $tool_name = $deployment->title;
+                    $deleted = $deployment->delete();
+                } else {
+                    PageLayout::postError(sprintf(_('Das LTI-Tool „%s“ ist in dieser Veranstaltung nicht vorhanden.'), htmlReady($this->tool->name)));
+                    return;
+                }
+            }
+        } else {
+            //Delete the tool directly:
+            $deleted = $this->tool->delete();
+        }
+        if ($deleted !== false) {
+            PageLayout::postSuccess(sprintf(_('Das LTI-Tool „%s“ wurde gelöscht.'), htmlReady($tool_name)));
+        } else {
+            PageLayout::postError(_('Das LTI-Tool „%s“ konnte nicht gelöscht werden.'), htmlReady($tool_name));
+        }
+        if ($range_id === 'global') {
+            //Redirect to the admin overview page.
+            $this->redirect('admin/lti');
+        } elseif (Course::exists($range_id)) {
+            //Redirect to the LTI module of the course:
+            $this->redirect('course/lti', ['cid' => $range_id]);
+        }
+    }
+}
diff --git a/app/views/admin/lti/edit.php b/app/views/admin/lti/edit.php
index 0473e863046..adc8c595f20 100644
--- a/app/views/admin/lti/edit.php
+++ b/app/views/admin/lti/edit.php
@@ -2,73 +2,30 @@
 /**
  * @var Admin_LtiController $controller
  * @var LtiTool $tool
+ * @var \OAT\Library\Lti1p3Core\Platform\Platform $platform
  */
 ?>
-<form class="default" action="<?= $controller->link_for('admin/lti/save/' . $tool->id) ?>" method="post">
+<form class="default" action="<?= $controller->link_for('admin/lti/edit/' . $tool->id) ?>"
+      method="post" data-dialog="reload-on-close">
     <?= CSRFProtection::tokenTag() ?>
     <fieldset>
         <legend>
             <?= _('Konfiguration des LTI-Tools') ?>
         </legend>
-
-        <label>
-            <span class="required">
-                <?= _('Name der Anwendung') ?>
-            </span>
-            <input type="text" name="name" value="<?= htmlReady($tool->name) ?>" required>
-        </label>
-
-        <label>
-            <span class="required">
-                <?= _('URL der Anwendung') ?>
-            </span>
-            <input type="text" name="launch_url" value="<?= htmlReady($tool->launch_url) ?>" required>
-        </label>
-
-        <label>
-            <span class="required">
-                <?= _('Consumer-Key') ?>
-            </span>
-            <input type="text" name="consumer_key" value="<?= htmlReady($tool->consumer_key) ?>" required>
-        </label>
-
-        <label>
-            <span class="required">
-                <?= _('Consumer-Secret') ?>
-            </span>
-            <input type="text" name="consumer_secret" value="<?= htmlReady($tool->consumer_secret) ?>" required>
-        </label>
-
-        <label>
-            <?= _('OAuth Signatur Methode') ?>
-            <select name="oauth_signature_method">
-                <option value="sha1">HMAC-SHA1</option>
-                <option value="sha256" <?=$tool->oauth_signature_method === 'sha256' ? 'selected' : '' ?>>HMAC-SHA256</option>
-            </select>
-        </label>
-
-        <label>
-            <input type="checkbox" name="allow_custom_url" value="1" <?= $tool->allow_custom_url ? ' checked' : '' ?>>
-            <?= _('Eingabe einer abweichenden URL im Kurs erlauben') ?>
-        </label>
-
-        <label>
-            <input type="checkbox" name="deep_linking" value="1" <?= $tool->deep_linking ? ' checked' : '' ?>>
-            <?= _('Auswahl von Inhalten über LTI Deep Linking (Content Item)') ?>
-        </label>
-
-        <label>
-            <input type="checkbox" name="send_lis_person" value="1" <?= $tool->send_lis_person ? ' checked' : '' ?>>
-            <?= _('Nutzerdaten an LTI-Tool senden') ?>
-            <?= tooltipIcon(_('Nutzerdaten dürfen nur an das externe Tool gesendet werden, wenn es keine Datenschutzbedenken gibt. Mit Setzen des Hakens bestätigen Sie, dass die Übermittlung der Daten zulässig ist.')) ?>
+        <label class="studiprequired">
+            <span class="textlabel"><?= _('Name') ?></span>
+            <span class="asterisk">*</span>
+            <input type="text" name="name" value="<?= htmlReady($tool->name) ?>">
         </label>
+        <?= $this->render_partial('lti/_tool_form_fields', ['tool' => $tool]) ?>
+    </fieldset>
 
-        <label>
-            <?= _('Zusätzliche LTI-Parameter') ?>
-            <?= tooltipIcon(_('Ein Wert pro Zeile, Beispiel: Review:Chapter=1.2.56')) ?>
-            <textarea name="custom_parameters"><?= htmlReady($tool->custom_parameters) ?></textarea>
-        </label>
+    <? if ($platform) : ?>
+    <fieldset>
+        <legend><?= _('Daten zur LTI-Plattform') ?></legend>
+        <?= $this->render_partial('lti/_platform_data', ['platform' => $platform]) ?>
     </fieldset>
+    <? endif ?>
 
     <footer data-dialog-button>
         <?= Studip\Button::createAccept(_('Speichern'), 'save') ?>
diff --git a/app/views/admin/lti/index.php b/app/views/admin/lti/index.php
index ad4ac50bf44..54ebd940ceb 100644
--- a/app/views/admin/lti/index.php
+++ b/app/views/admin/lti/index.php
@@ -4,64 +4,64 @@
  * @var LtiTool[] $tools
  */
 ?>
-<form action="" method="post">
-    <?= CSRFProtection::tokenTag() ?>
-    <table class="default">
-        <caption>
-            <?= _('Aktuell konfigurierte LTI-Tools') ?>
-        </caption>
+<? if ($tools) : ?>
+    <form action="" method="post">
+        <?= CSRFProtection::tokenTag() ?>
+        <table class="default">
+            <caption><?= _('Aktuell konfigurierte LTI-Tools') ?></caption>
 
-        <colgroup>
-            <col style="width: 30%;">
-            <col style="width: 40%;">
-            <col style="width: 20%;">
-            <col style="width: 5%;">
-            <col style="width: 5%;">
-        </colgroup>
+            <colgroup>
+                <col style="width: 30%;">
+                <col style="width: 40%;">
+                <col style="width: 20%;">
+                <col style="width: 5%;">
+                <col style="width: 5%;">
+            </colgroup>
 
-        <thead>
-            <tr>
-                <th><?= _('Name der Anwendung') ?></th>
-                <th><?= _('URL der Anwendung') ?></th>
-                <th><?= _('Consumer-Key') ?></th>
-                <th><?= _('Links') ?></th>
-                <th class="actions">
-                    <?= _('Aktionen') ?>
-                </th>
-            </tr>
-        </thead>
-
-        <tbody>
-            <? foreach ($tools as $tool): ?>
+            <thead>
                 <tr>
-                    <td>
-                        <a href="<?= $controller->link_for('admin/lti/edit/' . $tool->id) ?>" title="<?= _('LTI-Tool konfigurieren') ?>" data-dialog>
-                            <?= htmlReady($tool->name) ?>
-                        </a>
-                    </td>
-                    <td>
-                        <a href="<?= htmlReady($tool->launch_url) ?>" target="_blank" class="link-extern">
-                            <?= htmlReady($tool->launch_url) ?>
-                        </a>
-                    </td>
-                    <td>
-                        <?= htmlReady($tool->consumer_key) ?>
-                    </td>
-                    <td>
-                        <?= count($tool->links) ?>
-                    </td>
-                    <td class="actions">
-                        <a href="<?= $controller->link_for('admin/lti/edit/' . $tool->id) ?>" title="<?= _('LTI-Tool konfigurieren') ?>" data-dialog>
-                            <?= Icon::create('edit') ?>
-                        </a>
-                        <?= Icon::create('trash')->asInput([
-                            'formaction' => $controller->url_for('admin/lti/delete/' . $tool->id),
-                            'title' => _('LTI-Tool löschen'),
-                            'data-confirm' => sprintf(_('Wollen Sie wirklich das LTI-Tool "%s" löschen?'), $tool->name)
-                        ]) ?>
-                    </td>
+                    <th><?= _('Name der Anwendung') ?></th>
+                    <th><?= _('URL der Anwendung') ?></th>
+                    <th><?= _('Consumer-Key') ?></th>
+                    <th><?= _('LTI-Version') ?></th>
+                    <th><?= _('Links') ?></th>
+                    <th class="actions"><?= _('Aktionen') ?></th>
                 </tr>
-            <? endforeach ?>
-        </tbody>
-    </table>
-</form>
+            </thead>
+
+            <tbody>
+                <? foreach ($tools as $tool): ?>
+                    <tr>
+                        <td>
+                            <a href="<?= $controller->link_for('lti/tool/edit/global/' . $tool->id) ?>" data-dialog>
+                                <?= htmlReady($tool->name) ?>
+                            </a>
+                        </td>
+                        <td>
+                            <a href="<?= htmlReady($tool->launch_url) ?>" target="_blank" class="link-extern">
+                                <?= htmlReady($tool->launch_url) ?>
+                            </a>
+                        </td>
+                        <td><?= htmlReady($tool->consumer_key) ?></td>
+                        <td><?= htmlReady($tool->getLtiVersionString()) ?></td>
+                        <td><?= count($tool->links) ?></td>
+                        <td class="actions">
+                            <a href="<?= $controller->link_for('lti/tool/edit/global/' . $tool->id) ?>" title="<?= _('LTI-Tool konfigurieren') ?>"
+                               aria-label="<?= _('LTI-Tool konfigurieren') ?>" data-dialog>
+                                <?= Icon::create('edit') ?>
+                            </a>
+                            <?= Icon::create('trash')->asInput([
+                                'formaction'   => $controller->url_for('lti/tool/delete/global/' . $tool->id),
+                                'title'        => _('LTI-Tool löschen'),
+                                'data-confirm' => sprintf(_('Wollen Sie das LTI-Tool „%s“ wirklich löschen?'), htmlReady($tool->name)),
+                                'aria-label'   => _('LTI-Tool löschen'),
+                            ]) ?>
+                        </td>
+                    </tr>
+                <? endforeach ?>
+            </tbody>
+        </table>
+    </form>
+<? else : ?>
+    <?= MessageBox::info(_('Es sind keine globalen LTI-Tools konfiguriert.')) ?>
+<? endif ?>
diff --git a/app/views/course/lti/config.php b/app/views/course/lti/config.php
index e9994035a0b..fe487f92d1c 100644
--- a/app/views/course/lti/config.php
+++ b/app/views/course/lti/config.php
@@ -1,18 +1,25 @@
 <form class="default" action="<?= $controller->link_for('course/lti/save_config') ?>" method="post">
     <?= CSRFProtection::tokenTag() ?>
     <fieldset>
-        <legend>
-            <?= _('Einstellungen') ?>
-        </legend>
-
+        <legend><?= _('Datenschutzhinweis beim Wechsel in ein LTI-Tool') ?></legend>
+        <label>
+            <?= _('Text des Datenschutzhinweises') ?>
+            <textarea name="personal_data_warning"><?= htmlReady($personal_data_warning) ?></textarea>
+        </label>
         <label>
-            <span class="required">
-                <?= _('Titel des Reiters') ?>
-            </span>
-            <input type="text" name="title" value="<?= htmlReady($title) ?>" required>
+            <input type="checkbox" value="1" name="reset_warning"
+                   data-deactivates="textarea[name='personal_data_warning']">
+            <?= _('Den systemweit konfigurierten Standardtext verwenden.') ?>
         </label>
     </fieldset>
 
+    <fieldset>
+        <legend><?= _('LTI Plattform-Konfiguration') ?></legend>
+        <?= $this->render_partial('lti/_platform_data', [
+            'platform' => \Studip\LTI13a\PlatformManager::getPlatformConfiguration(),
+        ]) ?>
+    </fieldset>
+
     <footer data-dialog-button>
         <?= Studip\Button::createAccept(_('Speichern'), 'save') ?>
         <?= Studip\LinkButton::createCancel(_('Abbrechen'), $controller->url_for('course/lti')) ?>
diff --git a/app/views/course/lti/consent.php b/app/views/course/lti/consent.php
new file mode 100644
index 00000000000..4c810a5708f
--- /dev/null
+++ b/app/views/course/lti/consent.php
@@ -0,0 +1,72 @@
+<?php
+/**
+ * @var AuthenticatedController $controller
+ * @var LtiDeployment $deployment
+ * @var LtiDeploymentPrivacySettings $privacy_settings
+ */
+?>
+<? if ($deployment) : ?>
+    <form class="default" method="post" <?= $privacy_settings->isNew() ? 'data-dialog="reload-on-close"' : 'data-dialog' ?>
+          action="<?= $controller->link_for('course/lti/consent/' . $deployment->id) ?>">
+        <?= CSRFProtection::tokenTag() ?>
+        <?
+        $data_protection_warning = CourseConfig::get(Context::getId())->LTI_DATA_PROTECTION_COURSE_WARNING;
+        if (empty($data_protection_warning)) {
+            $data_protection_warning = Config::get()->LTI_DATA_PROTECTION_DEFAULT_WARNING;
+        }
+        ?>
+        <fieldset>
+            <legend><?= _('Datenschutzhinweise')  ?></legend>
+            <section>
+                <p><?= htmlReady($data_protection_warning) ?></p>
+                <? if ($deployment->tool->data_protection_notes) : ?>
+                    <p><?= formatReady($deployment->tool->data_protection_notes) ?></p>
+                <? endif ?>
+            </section>
+        </fieldset>
+        <fieldset>
+            <?
+            $optional_field_list = explode(',', $privacy_settings->allowed_optional_fields ?? '');
+            ?>
+            <legend><?= _('Folgenden Daten werden übertragen') ?></legend>
+            <?= _('Beim Wechsel in das LTI-Tool werden die folgenden personenbezogenen Daten übertragen:') ?>
+            <label>
+                <input type="checkbox" checked disabled>
+                <?= _('Die ID ihres Stud.IP-Kontos') ?>
+            </label>
+            <label>
+                <input type="checkbox" checked disabled>
+                <?= _('Ihr Vor- und Nachname, sowie gegebenenfalls vorhandene Titel') ?>
+            </label>
+            <label>
+                <input type="checkbox" checked disabled>
+                <?= _('Ihre E-Mail Adresse') ?>
+            </label>
+            <label>
+                <input type="checkbox" name="submit_optional_field[lang]" value="1"
+                    <?= in_array('lang', $optional_field_list) ? 'checked' : '' ?>>
+                <?= _('Ihre in Stud.IP eingestellte Sprache') ?>
+            </label>
+            <label>
+                <input type="checkbox" name="submit_optional_field[avatar_url]" value="1"
+                    <?= in_array('avatar_url', $optional_field_list) ? 'checked' : '' ?>>
+                <?= _('Ihr Profilbild') ?>
+            </label>
+        </fieldset>
+        <?= $this->render_partial('lti/_deployment_user_info', ['deployment' => $deployment]) ?>
+        <fieldset>
+            <legend><?= _('Bestätigung') ?></legend>
+            <label>
+                <input type="checkbox" name="confirmed" value="1" required>
+                <?= _(
+                    'Ich habe die Datenschutzhinweise zur Benutzung des LTI-Tools zur Kenntnis genommen und stimme der Weitergabe meiner personenbezogenen Daten zu. '
+                    . 'Mir ist bewusst, dass ich ohne die Zustimmung das LTI-Tool nicht nutzen kann.'
+                ) ?>
+            </label>
+            <div data-dialog-button>
+                <?= \Studip\Button::createAccept(_('Speichern'), 'save') ?>
+                <?= \Studip\Button::createCancel(_('Abbrechen')) ?>
+            </div>
+        </fieldset>
+    </form>
+<? endif ?>
diff --git a/app/views/course/lti/edit.php b/app/views/course/lti/edit.php
deleted file mode 100644
index 0131e05eacb..00000000000
--- a/app/views/course/lti/edit.php
+++ /dev/null
@@ -1,117 +0,0 @@
-<?php
-/**
- * @var Course_LtiController $controller
- * @var LtiData $lti_data
- * @var LtiTool[] $tools
- */
-?>
-<form class="default" action="<?= $controller->link_for('course/lti/save', $lti_data->isNew() ? '' : $lti_data->position) ?>" method="post">
-    <?= CSRFProtection::tokenTag() ?>
-    <fieldset>
-        <legend>
-            <?= _('Einstellungen') ?>
-        </legend>
-
-        <label>
-            <span class="required">
-                <?= _('Titel') ?>
-            </span>
-            <input type="text" name="title" value="<?= htmlReady($lti_data->title) ?>" required>
-        </label>
-
-        <label>
-            <?= _('Beschreibung') ?>
-            <textarea name="description" class="wysiwyg"><?= wysiwygReady($lti_data->description) ?></textarea>
-        </label>
-
-        <label>
-            <?= _('Auswahl des externen Tools') ?>
-            <select class="config_tool" name="tool_id">
-                <? foreach ($tools as $tool): ?>
-                    <option value="<?= $tool->id ?>"
-                        <? if ($tool->allow_custom_url): ?>
-                            data-url="<?= htmlReady($tool->launch_url) ?>"
-                        <? endif ?>
-                        <?= $lti_data->tool_id == $tool->id ? 'selected' : '' ?>><?= htmlReady($tool->name) ?></option>
-                <? endforeach ?>
-                <option value="0" <?= !$lti_data->tool_id ? 'selected' : '' ?>><?= _('Zugangsdaten selbst eingeben...') ?></option>
-            </select>
-        </label>
-
-        <div class="config_custom_url">
-            <label>
-                <?= _('URL der Anwendung (optional)') ?>
-                <?= tooltipIcon(_('Sie können direkt auf eine URL in der Anwendung verlinken.')) ?>
-                <input type="text" name="custom_url" value="<?= htmlReady($lti_data->launch_url) ?>">
-            </label>
-        </div>
-
-        <div class="config_launch_url">
-            <label>
-                <?= _('URL der Anwendung') ?>
-                <?= tooltipIcon(_('Die Betreiber dieses Tools müssen Ihnen eine URL und Zugangsdaten (Consumer-Key und Consumer-Secret) mitteilen.')) ?>
-                <input type="text" name="launch_url" value="<?= htmlReady($lti_data->launch_url) ?>">
-            </label>
-
-            <label>
-                <?= _('Consumer-Key des LTI-Tools') ?>
-                <input type="text" name="consumer_key" value="<?= htmlReady($lti_data->options['consumer_key'] ?? '') ?>">
-            </label>
-
-            <label>
-                <?= _('Consumer-Secret des LTI-Tools') ?>
-                <input type="text" name="consumer_secret" value="<?= htmlReady($lti_data->options['consumer_secret'] ?? '') ?>">
-            </label>
-
-            <label>
-                <?= _('OAuth Signatur Methode des LTI-Tools') ?>
-                <select name="oauth_signature_method">
-                    <option value="sha1">HMAC-SHA1</option>
-                    <option value="sha256" <?= isset($lti_data->options['oauth_signature_method']) && $lti_data->options['oauth_signature_method'] === 'sha256' ? 'selected' : '' ?>>HMAC-SHA256</option>
-                </select>
-            </label>
-
-            <label>
-                <input type="checkbox" name="send_lis_person" value="1" <?= !empty($lti_data->options['send_lis_person']) ? ' checked' : '' ?>>
-                <?= _('Nutzerdaten an LTI-Tool senden') ?>
-                <?= tooltipIcon(_('Nutzerdaten dürfen nur an das externe Tool gesendet werden, wenn es keine Datenschutzbedenken gibt. Mit Setzen des Hakens bestätigen Sie, dass die Übermittlung der Daten zulässig ist.')) ?>
-            </label>
-        </div>
-
-        <label>
-            <input type="checkbox" name="document_target" value="iframe" <?= isset($lti_data->options['document_target']) && $lti_data->options['document_target'] === 'iframe' ? ' checked' : '' ?>>
-            <?= _('Anzeige im IFRAME auf der Seite') ?>
-            <?= tooltipIcon(_('Normalerweise wird das externe Tool in einem neuen Fenster angezeigt. Aktivieren Sie diese Option, wenn die Anzeige stattdessen in einem IFRAME erfolgen soll.')) ?>
-        </label>
-
-        <label>
-            <?= _('Zusätzliche LTI-Parameter') ?>
-            <?= tooltipIcon(_('Ein Wert pro Zeile, Beispiel: Review:Chapter=1.2.56')) ?>
-            <textarea name="custom_parameters"><?= htmlReady($lti_data->options['custom_parameters'] ?? '') ?></textarea>
-        </label>
-    </fieldset>
-
-    <footer data-dialog-button>
-        <?= Studip\Button::createAccept(_('Speichern'), 'save') ?>
-        <?= Studip\LinkButton::createCancel(_('Abbrechen'), $controller->url_for('course/lti')) ?>
-    </footer>
-</form>
-
-<script>
-    $('.config_tool').change(function() {
-        let url = $(this).find(':selected').data('url');
-
-        if ($(this).val() == 0) {
-            $('.config_launch_url').show();
-        } else {
-            $('.config_launch_url').hide();
-        }
-
-        if (url) {
-            $('.config_custom_url').find('input').attr('placeholder', url);
-            $('.config_custom_url').show();
-        } else {
-            $('.config_custom_url').hide();
-        }
-    }).trigger('change');
-</script>
diff --git a/app/views/course/lti/grades.php b/app/views/course/lti/grades.php
index a200405fc96..e9b0b40fcbe 100644
--- a/app/views/course/lti/grades.php
+++ b/app/views/course/lti/grades.php
@@ -1,39 +1,41 @@
-<table class="default">
-    <caption>
-        <?= _('Ergebnisse') ?>
-    </caption>
+<? if (empty($lti_data_array)): ?>
+    <?= MessageBox::info(_('Es sind keine LTI-Tools konfiguriert.')) ?>
+<? else : ?>
+    <table class="default">
+        <caption><?= _('Ergebnisse') ?></caption>
 
-    <thead>
-        <tr class="sortable">
-            <th class="<?= $desc ? 'sortdesc' : 'sortasc' ?>">
-                <a href="<?= $controller->link_for('course/lti/grades', ['desc' => !$desc]) ?>">
-                    <?= _('Teilnehmende') ?>
-                </a>
-            </th>
-            <? foreach ($lti_data_array as $lti_data): ?>
-                <th style="text-align: right;">
-                    <?= htmlReady($lti_data->title) ?>
+        <thead>
+            <tr class="sortable">
+                <th class="<?= $desc ? 'sortdesc' : 'sortasc' ?>">
+                    <a href="<?= $controller->link_for('course/lti/grades', ['desc' => !$desc]) ?>">
+                        <?= _('Teilnehmende') ?>
+                    </a>
                 </th>
-            <? endforeach ?>
-        </tr>
-    </thead>
-
-    <tbody>
-        <? foreach ($members as $member): ?>
-            <tr>
-                <td>
-                    <?= htmlReady($member->nachname) ?>, <?= htmlReady($member->vorname) ?>
-                </td>
                 <? foreach ($lti_data_array as $lti_data): ?>
-                    <td style="text-align: right;">
-                        <? if ($grade = $lti_data->grades->findOneBy('user_id', $member->user_id)): ?>
-                            <?= sprintf('%.0f%%', $grade->score * 100) ?>
-                        <? else: ?>
-                            &ndash;
-                        <? endif ?>
-                    </td>
+                    <th style="text-align: right;">
+                        <?= htmlReady($lti_data->title) ?>
+                    </th>
                 <? endforeach ?>
             </tr>
-        <? endforeach ?>
-    </tbody>
-</table>
+        </thead>
+
+        <tbody>
+            <? foreach ($members as $member): ?>
+                <tr>
+                    <td>
+                        <?= htmlReady($member->nachname) ?>, <?= htmlReady($member->vorname) ?>
+                    </td>
+                    <? foreach ($lti_data_array as $lti_data): ?>
+                        <td style="text-align: right;">
+                            <? if ($grade = $lti_data->grades->findOneBy('user_id', $member->user_id)): ?>
+                                <?= sprintf('%.0f%%', $grade->score * 100) ?>
+                            <? else: ?>
+                                &ndash;
+                            <? endif ?>
+                        </td>
+                    <? endforeach ?>
+                </tr>
+            <? endforeach ?>
+        </tbody>
+    </table>
+<? endif ?>
diff --git a/app/views/course/lti/grades_user.php b/app/views/course/lti/grades_user.php
index 8be7b0270fa..fb041c57703 100644
--- a/app/views/course/lti/grades_user.php
+++ b/app/views/course/lti/grades_user.php
@@ -1,33 +1,29 @@
-<table class="default">
-    <caption>
-        <?= _('Ergebnisse') ?>
-    </caption>
+<? if (empty($lti_data_array)): ?>
+    <?= MessageBox::info(_('Es sind keine LTI-Tools konfiguriert.')) ?>
+<? else : ?>
+    <table class="default">
+        <caption><?= _('Ergebnisse') ?></caption>
 
-    <thead>
-        <tr>
-            <th>
-                <?= _('Abschnitt') ?>
-            </th>
-            <th style="text-align: right;">
-                <?= _('Bewertung') ?>
-            </th>
+        <thead>
+            <tr>
+                <th><?= _('Abschnitt') ?></th>
+                <th style="text-align: right;"><?= _('Bewertung') ?></th>
         </tr>
-    </thead>
+        </thead>
 
-    <tbody>
-        <? foreach ($lti_data_array as $lti_data): ?>
-            <tr>
-                <td>
-                    <?= htmlReady($lti_data->title) ?>
-                </td>
-                <td style="text-align: right;">
-                    <? if ($grade = LtiGrade::find([$lti_data->id, $GLOBALS['user']->id])): ?>
-                        <?= sprintf('%.0f%%', $grade->score * 100) ?>
-                    <? else: ?>
-                        &ndash;
-                    <? endif ?>
-                </td>
-            </tr>
-        <? endforeach ?>
-    </tbody>
-</table>
+        <tbody>
+            <? foreach ($lti_data_array as $lti_data): ?>
+                <tr>
+                    <td><?= htmlReady($lti_data->title) ?></td>
+                    <td style="text-align: right;">
+                        <? if ($grade = LtiGrade::find([$lti_data->id, $GLOBALS['user']->id])): ?>
+                            <?= sprintf('%.0f%%', $grade->score * 100) ?>
+                        <? else: ?>
+                            &ndash;
+                        <? endif ?>
+                    </td>
+                </tr>
+            <? endforeach ?>
+        </tbody>
+    </table>
+<? endif ?>
diff --git a/app/views/course/lti/iframe.php b/app/views/course/lti/iframe.php
index 1cd0d2c53c7..c1a7de1b8be 100644
--- a/app/views/course/lti/iframe.php
+++ b/app/views/course/lti/iframe.php
@@ -1,24 +1,42 @@
 <?php
 /**
- * @var string $launch_url
+ * @var StudipController $controller
+ * @var LtiDeployment $deployment
  * @var array $launch_data
  * @var string $signature
+ * @var bool $lti13a_mode
+ * @var \OAT\Library\Lti1p3Core\Message\LtiMessage $message
  */
 ?>
-<!DOCTYPE html>
-<html>
-<head>
+<? if ($deployment) : ?>
+   <!DOCTYPE html>
+    <html>
+    <head>
     <meta charset="UTF-8">
-</head>
-<body onload="document.ltiLaunchForm.submit();">
-    <form name="ltiLaunchForm" method="post" action="<?= htmlReady($launch_url) ?>">
-        <? foreach ($launch_data as $key => $value): ?>
-            <input type="hidden" name="<?= htmlReady($key) ?>" value="<?= htmlReady($value, false) ?>">
-        <? endforeach ?>
-        <input type="hidden" name="oauth_signature" value="<?= $signature ?>">
-        <noscript>
-            <button><?= _('Anwendung starten') ?></button>
-        </noscript>
-    </form>
-</body>
-</html>
+        <? if (!$lti13a_mode) : ?>
+            <script type="text/javascript">
+                window.onload=document.ltiLaunchForm.submit();
+            </script>
+        <? endif ?>
+    </head>
+    <body>
+        <? if ($lti13a_mode) : ?>
+            <? if ($message) : ?>
+                <?= $message->toHtmlRedirectForm(Request::submitted('do_not_send') ? false : true) ?>
+            <? else: ?>
+                <?= _('Das LTI-Tool kann nicht aufgerufen werden.') ?>
+            <? endif ?>
+        <? else : ?>
+            <form name="ltiLaunchForm" method="post" action="<?= htmlReady($deployment->getLaunchUrl()) ?>">
+                <? foreach ($launch_data as $key => $value): ?>
+                    <input type="hidden" name="<?= htmlReady($key) ?>" value="<?= htmlReady($value, false) ?>">
+                <? endforeach ?>
+                <input type="hidden" name="oauth_signature" value="<?= $signature ?>">
+                <noscript>
+                    <button><?= _('Anwendung starten') ?></button>
+                </noscript>
+            </form>
+        <? endif ?>
+    </body>
+    </html>
+<? endif ?>
diff --git a/app/views/course/lti/index.php b/app/views/course/lti/index.php
index df1696bee4f..9c399d625d3 100644
--- a/app/views/course/lti/index.php
+++ b/app/views/course/lti/index.php
@@ -1,20 +1,30 @@
 <?php
 /**
  * @var Course_LtiController $controller
- * @var LtiData[] $lti_data_array
+ * @var LtiDeployment[] $lti_data_array
  * @var bool $edit_perm
  */
 ?>
 <? if (empty($lti_data_array)): ?>
-    <?= MessageBox::info(_('Es wurden noch keine Inhalte angelegt.')) ?>
+    <?= MessageBox::info(_('Es sind keine LTI-Tools konfiguriert.')) ?>
 <? endif ?>
 
 <? foreach ($lti_data_array as $lti_data): ?>
-    <? $launch_url = $lti_data->getLaunchURL() ?>
+    <?
+    $launch_url = $lti_data->getLaunchURL();
+    $unfinished_deep_linking = !empty($lti_data->options['unfinished_deep_linking']);
+    $no_consent = !LtiToolPrivacySettings::countBySql(
+        '`tool_id` = :tool_id AND `user_id` = :user_id',
+        ['tool_id' => $lti_data->tool_id, 'user_id' => $GLOBALS['user']->id]
+    );
+    ?>
 
     <article class="studip">
         <header>
-            <h1><?= htmlReady($lti_data->title) ?></h1>
+            <h1>
+                <?= htmlReady($lti_data->title) ?>
+                <?= $unfinished_deep_linking ? '(' . _('LTI Deep Linking noch nicht fertig eingerichtet') . ')' : '' ?>
+            </h1>
 
             <? if ($edit_perm): ?>
                 <nav>
@@ -22,47 +32,91 @@
                         <?= CSRFProtection::tokenTag() ?>
                         <? if ($lti_data->position > 0): ?>
                             <?= Icon::create('arr_2up', Icon::ROLE_SORT)->asInput([
-                                'formaction' => $controller->url_for('course/lti/move/' . $lti_data->position . '/up')
+                                'formaction' => $controller->url_for('course/lti/move/' . $lti_data->id . '/up'),
+                                'title'      => _('Nach oben verschieben'),
+                                'aria-label' => _('Nach oben verschieben')
                             ]) ?>
                         <? endif ?>
                         <? if ($lti_data->position < count($lti_data_array) - 1): ?>
                             <?= Icon::create('arr_2down', Icon::ROLE_SORT)->asInput([
-                                'formaction' => $controller->url_for('course/lti/move/' . $lti_data->position . '/down')
+                                'formaction' => $controller->url_for('course/lti/move/' . $lti_data->id . '/down'),
+                                'title'      => _('Nach unten verschieben'),
+                                'aria-label' => _('Nach unten verschieben')
                             ]) ?>
                         <? endif ?>
 
-                        <?= Icon::create('edit')->asInput([
-                            'formaction' => $controller->url_for('course/lti/edit/' . $lti_data->position),
-                            'title' => _('Abschnitt bearbeiten'),
-                            'data-dialog' => ''
-                        ]) ?>
-                        <?= Icon::create('trash')->asInput([
-                            'formaction' => $controller->url_for('course/lti/delete/' . $lti_data->position),
-                            'title' => _('Abschnitt löschen'),
-                            'data-confirm' => sprintf(_('Wollen Sie wirklich den Abschnitt "%s" löschen?'), $lti_data->title)
-                        ]) ?>
+                        <?
+                        $menu = ActionMenu::get();
+                        $show_admin_actions = $GLOBALS['perm']->have_studip_perm('tutor', $lti_data->course_id);
+                        if ($show_admin_actions) {
+                            $menu->addLink(
+                                $controller->url_for('lti/tool/index/' . $lti_data->course_id . '/' . $lti_data->tool->id),
+                                _('Konfiguration des LTI-Tools anzeigen'),
+                                Icon::create('info-circle'),
+                                ['data-dialog' => 'size=default']
+                            );
+                        }
+                        $menu->addLink(
+                            $controller->url_for('course/lti/consent/' . $lti_data->id),
+                            _('Datenschutzeinstellungen'),
+                            Icon::create('privacy'),
+                            ['data-dialog' => 'size=default']
+                        );
+
+                        if ($show_admin_actions) {
+                            $menu->addLink(
+                                $controller->url_for('lti/tool/edit/' . $lti_data->course_id . '/' . $lti_data->tool->id),
+                                _('LTI-Tool konfigurieren'),
+                                Icon::create('edit'),
+                                ['data-dialog' => 'size=default']
+                            );
+                            $menu->addLink(
+                                sprintf(
+                                    'javascript:void(STUDIP.Dialog.confirmAsPost(\'%s\', \'%s\'))',
+                                    sprintf(_('Wollen Sie das LTI-Tool "%s" wirklich entfernen?'), $lti_data->title),
+                                    $controller->url_for('lti/tool/delete/' . $lti_data->course_id . '/' . $lti_data->tool->id)
+                                ),
+                                _('LTI-Tool entfernen'),
+                                Icon::create('trash')
+                            );
+                        }
+                        ?>
+                        <?= $menu->render() ?>
                     </form>
                 </nav>
             <? endif ?>
         </header>
-
         <section>
-            <?= formatReady($lti_data->description) ?>
-
-            <? if ($launch_url && $lti_data->options['document_target'] === 'iframe'): ?>
-                <iframe style="border: none; height: 640px; width: 100%;"
-                        src="<?= $controller->link_for('course/lti/iframe', $lti_data->position) ?>"></iframe>
-            <? endif ?>
-        </section>
-
-        <? if ($launch_url && $lti_data->options['document_target'] !== 'iframe'): ?>
-            <footer>
+            <? if ($unfinished_deep_linking) : ?>
                 <?= Studip\LinkButton::create(
-                    _('Anwendung starten'),
-                    $controller->link_for('course/lti/iframe', $lti_data->position),
+                    _('Einrichtung abschließen'),
+                    $controller->url_for('course/lti/select_link/' . $lti_data->id, ['tool_id' => $lti_data->tool_id]),
                     ['target' => '_blank']
                 ) ?>
-            </footer>
-        <? endif ?>
+            <? elseif ($no_consent) : ?>
+                <?= formatReady($lti_data->description) ?>
+                <p><?= _('Sie haben der Datenweitergabe an das LTI-Tool noch nicht zugestimmt und können es deswegen noch nicht nutzen.') ?></p>
+                <?= Studip\LinkButton::create(
+                    _('Datenschutzeinstellungen öffnen'),
+                    $controller->url_for('course/lti/consent/' . $lti_data->id),
+                    ['data-dialog' => 'reload-on-close']
+                ) ?>
+            <? elseif ($launch_url) : ?>
+                <?
+                $document_target = $lti_data->options['document_target'] ?? '';
+                ?>
+                <?= formatReady($lti_data->description) ?>
+                <? if ($document_target === 'iframe') : ?>
+                    <iframe style="border: none; height: 640px; width: 100%;"
+                            src="<?= $controller->link_for('course/lti/iframe/' . $lti_data->id) ?>"></iframe>
+                <? else : ?>
+                    <?= Studip\LinkButton::create(
+                        _('Anwendung starten'),
+                        $controller->url_for('course/lti/iframe/' . $lti_data->id),
+                        ['target' => '_blank']
+                    ) ?>
+                <? endif ?>
+            <? endif ?>
+        </section>
     </article>
 <? endforeach ?>
diff --git a/app/views/course/lti/select_link.php b/app/views/course/lti/select_link.php
new file mode 100644
index 00000000000..370587e842f
--- /dev/null
+++ b/app/views/course/lti/select_link.php
@@ -0,0 +1,15 @@
+<?php
+/**
+ * @var AuthenticatedController $controller
+ * @var LtiTool $tool
+ * @var LtiDeployment $deployment
+ */
+?>
+<form class="default" method="post"
+      action="<?= $controller->link_for('course/lti/process_select_link/' . htmlReady($deployment->id), ['tool_id' => $tool->id]) ?>">
+    <?= CSRFProtection::tokenTag() ?>
+    <?= $this->render_partial('lti/_tool_info', ['tool' => $tool, 'deployment' => $deployment]) ?>
+    <div data-dialog-button>
+        <?= \Studip\Button::create(_('Weiter'), 'continue') ?>
+    </div>
+</form>
diff --git a/app/views/course/lti/select_tool.php b/app/views/course/lti/select_tool.php
new file mode 100644
index 00000000000..b6163e0859a
--- /dev/null
+++ b/app/views/course/lti/select_tool.php
@@ -0,0 +1,31 @@
+<?php
+/**
+ * @var StudipController $controller
+ * @var LtiTool[] $global_tools
+ */
+?>
+<form class="default" method="post" action="<?= $controller->link_for('course/lti/select_tool_redirect') ?>"
+      data-dialog>
+    <?= CSRFProtection::tokenTag() ?>
+    <fieldset>
+        <legend><?= _('Auswahl des LTI-Tools') ?></legend>
+        <label>
+            <?= _('Bitte wählen Sie ein LTI-Tool aus.') ?>
+            <select name="selected_tool_id">
+                <? foreach ($global_tools as $tool) : ?>
+                    <option value="<?= htmlReady($tool->id) ?>">
+                        <?= htmlReady($tool->name) ?>
+                    </option>
+                <? endforeach ?>
+                <? if (Config::get()->LTI_ALLOW_TOOL_CONFIG_IN_COURSE) : ?>
+                    <option value="new">
+                        <?= _('Neues LTI-Tool für die Veranstaltung einrichten.') ?>
+                    </option>
+                <? endif ?>
+            </select>
+        </label>
+    </fieldset>
+    <div data-dialog-button>
+        <?= \Studip\Button::create(_('Weiter')) ?>
+    </div>
+</form>
diff --git a/app/views/lti/_deployment_user_info.php b/app/views/lti/_deployment_user_info.php
new file mode 100644
index 00000000000..b43da8fba15
--- /dev/null
+++ b/app/views/lti/_deployment_user_info.php
@@ -0,0 +1,43 @@
+<?
+/**
+ * @var LtiDeployment $deployment
+ */
+?>
+<? if (!empty($deployment)) : ?>
+    <article class="studip">
+        <header><h1><?= htmlReady($deployment->title) ?></h1></header>
+        <section>
+            <? if ($deployment->tool->range_id === 'global') : ?>
+                <p>
+                    <?= sprintf(
+                        'Dies ist eine Einbindung des LTI-Tools „%s“.',
+                        htmlReady($deployment->tool->name)
+                    ) ?>
+                </p>
+            <? endif ?>
+            <p><?= formatReady($deployment->description ?? '') ?></p>
+            <?
+            $url_parts = parse_url($deployment->getLaunchURL());
+            ?>
+            <? if (!empty($url_parts['host'])) : ?>
+                <p><?= _('Domain') ?>: <?= htmlReady($url_parts['host']) ?></p>
+            <? endif ?>
+            <? if ($deployment->tool->terms_of_use_url || $deployment->tool->privacy_policy_url) : ?>
+                <p>
+                    <? if ($deployment->tool->terms_of_use_url) : ?>
+                        <a href="<?= htmlReady($deployment->tool->terms_of_use_url) ?>">
+                            <?= Icon::create('link-extern')->asImg(['class' => 'text-bottom']) ?>
+                            <?= _('Nutzungsbedingungen') ?>
+                        </a>
+                    <? endif ?>
+                    <? if ($deployment->tool->privacy_policy_url) : ?>
+                        <a href="<?= htmlReady($deployment->tool->privacy_policy_url) ?>">
+                            <?= Icon::create('link-extern')->asImg(['class' => 'text-bottom']) ?>
+                            <?= _('Datenschutzerklärung') ?>
+                        </a>
+                    <? endif ?>
+                </p>
+            <? endif ?>
+        </section>
+    </article>
+<? endif ?>
diff --git a/app/views/lti/_platform_data.php b/app/views/lti/_platform_data.php
new file mode 100644
index 00000000000..e9e2979348c
--- /dev/null
+++ b/app/views/lti/_platform_data.php
@@ -0,0 +1,45 @@
+<?php
+/**
+ * @var \OAT\Library\Lti1p3Core\Platform\Platform $platform
+ */
+?>
+<dl>
+    <dt><?= _('Plattform-ID') ?></dt>
+    <dd>
+        <a href="<?= htmlReady($platform->getAudience()) ?>">
+            <?= htmlReady($platform->getAudience()) ?>
+        </a>
+    </dd>
+
+    <dt><?= _('OAuth2 access token URL') ?></dt>
+    <dd>
+        <a href="<?= htmlReady($platform->getOAuth2AccessTokenUrl()) ?>">
+            <?= htmlReady($platform->getOAuth2AccessTokenUrl()) ?>
+        </a>
+    </dd>
+
+    <dt><?= _('OIDC authentication URL') ?></dt>
+    <dd>
+        <a href="<?= htmlReady($platform->getOidcAuthenticationUrl()) ?>">
+            <?= htmlReady($platform->getOidcAuthenticationUrl()) ?>
+        </a>
+    </dd>
+
+    <dt><?= _('JWKS URL') ?></dt>
+    <dd>
+        <a href="<?= URLHelper::getLink('dispatch.php/lti/auth/jwks', [], true) ?>">
+            <?= URLHelper::getLink('dispatch.php/lti/auth/jwks', [], true) ?>
+        </a>
+    </dd>
+
+    <?
+    $keyring = \Studip\LTI13a\PlatformManager::getPlatformKeyring();
+    if (!$keyring) {
+        $keyring = \Studip\LTI13a\PlatformManager::generatePlatformKeyring();
+    }
+    ?>
+    <? if ($keyring) : ?>
+        <dt><?= _('Öffentlicher Schlüssel') ?></dt>
+        <dd><pre><?= htmlReady($keyring->public_key) ?></pre></dd>
+    <? endif ?>
+</dl>
diff --git a/app/views/lti/_tool_form_fields.php b/app/views/lti/_tool_form_fields.php
new file mode 100644
index 00000000000..7a7dfbff524
--- /dev/null
+++ b/app/views/lti/_tool_form_fields.php
@@ -0,0 +1,148 @@
+<?php
+/**
+ * @var LtiTool $tool
+ * @var LtiDeployment $deployment
+ */
+?>
+<fieldset>
+    <legend><?= _('Grunddaten') ?></legend>
+    <label class="studiprequired">
+        <span class="textlabel"><?= _('Titel') ?></span>
+        <span class="asterisk">*</span>
+        <input type="text" name="name" required
+               value="<?= htmlReady(!empty($deployment) ? $deployment->title : $tool->name ?? '') ?>">
+    </label>
+    <? if (!empty($deployment)) : ?>
+        <label>
+            <?= _('Beschreibung') ?>
+            <textarea name="description" class="wysiwyg"><?= wysiwygReady($deployment->description ?? '') ?></textarea>
+        </label>
+        <label>
+            <?= _('Datenschutzhinweise') ?>
+            <textarea name="data_protection_notes" class="wysiwyg"
+                      placeholder="<?= _('Bitte machen Sie Angaben zu dem angebundenen Werkzeug, soweit sie ihnen bekannt sind. Wie ist der Name, wer bietet es an, wozu wird es eingesetzt und welche Daten werden übertragen? (Beispiel: „Tool XY wird zur Durchführung von Sprachtests genutzt und Testergebnisse und ggf. Noten gespeichert. Zur Anmeldung werden Name und Nutzerkennung übertragen.“)') ?>"><?= wysiwygReady($tool->data_protection_notes) ?></textarea>
+        </label>
+    <? endif ?>
+    <? if ($tool->isEditableByUser()) : ?>
+        <label>
+            <?= _('URL zu den Nutzungsbedingungen des LTI-Tools (falls verfügbar)') ?>
+            <input type="url" name="terms_of_use_url" value="<?= htmlReady($tool->terms_of_use_url) ?>">
+        </label>
+        <label>
+            <?= _('URL zur Datenschutzerklärung des LTI-Tools (falls verfügbar)') ?>
+            <input type="url" name="privacy_policy_url" value="<?= htmlReady($tool->privacy_policy_url) ?>">
+        </label>
+    <? endif ?>
+</fieldset>
+<fieldset>
+    <legend><?= _('Konfiguration des LTI-Tools') ?></legend>
+    <? if ($tool->isEditableByUser()) : ?>
+        <label class="studiprequired">
+            <span class="textlabel"><?= _('LTI-Version') ?></span>
+            <span class="asterisk">*</span>
+            <select name="lti_version"
+                    data-shows=".lti11-field" data-hides=".lti13a-field"
+                    data-triggering-value="1.1">
+                <option value="1.1" <?= !empty($tool->lti_version) && $tool->lti_version === '1.1' ? 'selected' : '' ?>>
+                    1.0/1.1
+                </option>
+                <option value="1.3a" <?= empty($tool->lti_version) || $tool->lti_version === '1.3a' ? 'selected' : '' ?>>
+                    1.3a
+                </option>
+            </select>
+        </label>
+    <? endif ?>
+
+    <label class="studiprequired">
+        <span class="textlabel"><?= _('LTI Launch-URL') ?></span>
+        <span class="asterisk">*</span>
+        <input type="text" name="launch_url" required
+               value="<?= htmlReady(
+                   !empty($deployment->launch_url)
+                       ? $deployment->launch_url
+                       : $tool->launch_url ?? ''
+               ) ?>">
+    </label>
+
+    <? if ($tool->isEditableByUser()) : ?>
+        <div class="lti13a-field">
+            <label>
+                <?= _('OIDC Login-URL') ?>
+                <?= tooltipIcon(_('Die URL, mit der der Login via OpenID Connect stattfindet.')) ?>
+                <input type="text" name="oidc_init_url" value="<?= htmlReady($tool->oidc_init_url ?? '') ?>">
+            </label>
+            <label>
+                <?= _('Deep-linking URL') ?>
+                <input type="url" name="deep_linking_url" value="<?= htmlReady($tool->deep_linking_url ?? '') ?>">
+            </label>
+            <label>
+                <?= _('JWKS-URL') ?>
+                <?= tooltipIcon(_('Die URL, mit der der der Austausch von JSON web keys stattfinden kann.')) ?>
+                <input type="text" name="jwks_url"
+                       value="<?= htmlReady($tool->jwks_url ?? '') ?>">
+            </label>
+            <label>
+                <?= _('Schlüssel-ID') ?>
+                <?= tooltipIcon(_('Die ID des Schlüssels, der über die JWKS-URL geladen werden soll.')) ?>
+                <input type="text" name="jwks_key_id" value="<?= htmlReady($tool->jwks_key_id ?? '') ?>">
+            </label>
+            <label>
+                <?= _('Öffentlicher Schlüssel des LTI-Tools') ?>
+                <?
+                $keyring = null;
+                if ($tool && !$tool->isNew()) {
+                    $keyring = $tool->getKeyring();
+                }
+                $public_key_string = '';
+                if ($keyring) {
+                    $keychain = $keyring->toKeyChain();
+                    $public_key_string = $keychain->getPublicKey()->getContent();
+                }
+                ?>
+                <textarea name="tool_public_key"><?= htmlReady($public_key_string) ?></textarea>
+            </label>
+        </div>
+        <div class="lti11-field">
+            <label class="studiprequired">
+                <span class="textlabel"><?= _('Consumer-Key des LTI-Tools') ?></span>
+                <span class="asterisk">*</span>
+                <input type="text" name="consumer_key" required
+                       value="<?= htmlReady($tool->consumer_key ?? '') ?>">
+            </label>
+            <label class="studiprequired">
+            <span class="textlabel"><?= _('Consumer-Secret des LTI-Tools') ?></span>
+                <span class="asterisk">*</span>
+                <input type="text" name="consumer_secret" required
+                       value="<?= htmlReady($tool->consumer_secret ?? '') ?>">
+            </label>
+        </div>
+        <label>
+            <input type="checkbox" name="send_lis_person" value="1" <?= !empty($tool->send_lis_person) ? ' checked' : '' ?>>
+            <?= _('Personendaten an das LTI-Tool senden') ?>
+            <?= tooltipIcon(_('Personendaten dürfen nur an das externe Tool gesendet werden, wenn es keine Datenschutzbedenken gibt. Mit Setzen des Hakens bestätigen Sie, dass die Übermittlung der Daten zulässig ist.')) ?>
+        </label>
+    <? endif ?>
+    <label>
+        <?= _('Zusätzliche LTI-Parameter') ?>
+        <?= tooltipIcon(_('Ein Wert pro Zeile, Beispiel: Review:Chapter=1.2.56')) ?>
+        <textarea name="custom_parameters"><?= htmlReady(
+                !empty($deployment->options['custom_parameters'])
+                    ? $deployment->options['custom_parameters']
+                    : $tool->custom_parameters ?? ''
+            ) ?></textarea>
+    </label>
+</fieldset>
+<? if (!empty($deployment)) : ?>
+    <fieldset>
+        <legend><?= _('Anzeigeeinstellungen') ?></legend>
+        <label>
+            <input type="checkbox" name="document_target" value="iframe" <?= isset($deployment->options['document_target']) && $deployment->options['document_target'] === 'iframe' ? ' checked' : '' ?>>
+            <?= _('Anzeige im IFRAME auf der Seite') ?>
+            <?= tooltipIcon(_('Normalerweise wird das externe Tool in einem neuen Fenster angezeigt. Aktivieren Sie diese Option, wenn die Anzeige stattdessen in einem IFRAME erfolgen soll.')) ?>
+        </label>
+    </fieldset>
+<? endif ?>
+
+<footer data-dialog-button>
+    <?= Studip\Button::createAccept(_('Speichern'), 'save') ?>
+</footer>
diff --git a/app/views/lti/_tool_info.php b/app/views/lti/_tool_info.php
new file mode 100644
index 00000000000..452e8f48f98
--- /dev/null
+++ b/app/views/lti/_tool_info.php
@@ -0,0 +1,79 @@
+<?php
+/**
+ * @var LtiTool $tool
+ * @var LtiDeployment $deployment
+ * @var StudipControlle $controller
+ */
+?>
+<? if (!empty($tool)) : ?>
+    <article class="studip">
+        <header>
+            <? if ($deployment) : ?>
+                <h1><?= htmlReady($deployment->title) ?></h1>
+            <? else : ?>
+                <h1><?= htmlReady($tool->name) ?></h1>
+            <? endif ?>
+        </header>
+        <dl>
+            <dt><?= _('Launch-URL') ?></dt>
+            <dd>
+                <? if ($deployment && $deployment->launch_url) : ?>
+                    <a href="<?= htmlReady($deployment->launch_url) ?>">
+                        <?= Icon::create('link-extern')->asImg(['class' => 'text-bottom']) ?>
+                        <?= htmlReady($deployment->launch_url) ?>
+                    </a>
+                <? else : ?>
+                    <a href="<?= htmlReady($tool->launch_url) ?>">
+                        <?= Icon::create('link-extern')->asImg(['class' => 'text-bottom']) ?>
+                        <?= htmlReady($tool->launch_url) ?>
+                    </a>
+                <? endif ?>
+            </dd>
+
+            <? if ($tool->terms_of_use_url) : ?>
+                <dt><?= _('Nutzungsbedingungen') ?></dt>
+                <dd>
+                    <a href="<?= htmlReady($tool->terms_of_use_url) ?>">
+                        <?= Icon::create('link-extern') ?>
+                        <?= htmlReady($tool->terms_of_use_url) ?>
+                    </a>
+                </dd>
+            <? endif ?>
+            <? if ($tool->privacy_policy_url) : ?>
+                <dt><?= _('Datenschutzerklärung') ?></dt>
+                <dd>
+                    <a href="<?= htmlReady($tool->privacy_policy_url) ?>">
+                        <?= Icon::create('link-extern') ?>
+                        <?= htmlReady($tool->terms_of_use_url) ?>
+                    </a>
+                </dd>
+            <? endif ?>
+
+            <? if ($tool) : ?>
+                <dt><?= _('Client-ID') ?></dt>
+                <dd><?= htmlReady($tool->id) ?></dd>
+            <? endif ?>
+
+            <? if ($deployment) : ?>
+                <dt><?= _('Deployment-ID') ?></dt>
+                <dd><?= htmlReady($deployment->id) ?></dd>
+
+                <? if ($parameters = $deployment->getCustomParameters()) : ?>
+                    <dt><?= _('LTI custom parameters') ?></dt>
+                    <dd><?= htmlReady($parameters) ?></dd>
+                <? endif ?>
+            <? endif ?>
+            <dt><?= _('Direktlink zum LTI-Tool') ?></dt>
+            <dd>
+                <a href="<?= $controller->link_for('course/lti/iframe', $deployment->id) ?>">
+                    <?= Icon::create('link-extern')->asImg(['class' => 'text-bottom']) ?>
+                    <?= $controller->link_for('course/lti/iframe', $deployment->id) ?>
+                </a>
+            </dd>
+        </dl>
+    </article>
+    <article class="studip">
+        <header><h1><?= _('Plattform-Konfiguration') ?></h1></header>
+        <?= $this->render_partial('lti/_platform_data', ['platform' => \Studip\LTI13a\PlatformManager::getPlatformConfiguration()]) ?>
+    </article>
+<? endif ?>
diff --git a/app/views/lti/tool/add.php b/app/views/lti/tool/add.php
new file mode 100644
index 00000000000..fda0849c060
--- /dev/null
+++ b/app/views/lti/tool/add.php
@@ -0,0 +1,16 @@
+<?php
+/**
+ * @var AuthenticatedController $controller
+ * @var string $range_id
+ * @var LtiTool $tool
+ * @var LtiDeployment $deployment
+ */
+?>
+<form class="default" method="post" data-dialog="reload-on-close"
+      action="<?= $controller->link_for('lti/tool/add/' . $range_id . '/' . $tool->id) ?>">
+    <?= CSRFProtection::tokenTag() ?>
+    <?= $this->render_partial('lti/_tool_form_fields', [
+        'tool'       => $tool,
+        'deployment' => $deployment,
+    ]) ?>
+</form>
diff --git a/app/views/lti/tool/edit.php b/app/views/lti/tool/edit.php
new file mode 100644
index 00000000000..5b29dd3340f
--- /dev/null
+++ b/app/views/lti/tool/edit.php
@@ -0,0 +1,18 @@
+<?
+/**
+ * @var AuthenticatedController $controller
+ * @var string $range_id
+ * @var LtiTool $tool
+ * @var LtiDeployment $deployment
+ */
+?>
+<? if ($tool) : ?>
+    <form class="default" method="post" data-dialog="reload-on-close"
+          action="<?= $controller->link_for('lti/tool/edit/' . $range_id . '/' . $tool->id) ?>">
+        <?= CSRFProtection::tokenTag() ?>
+        <?= $this->render_partial('lti/_tool_form_fields', [
+            'tool'       => $tool,
+            'deployment' => $deployment,
+        ]) ?>
+    </form>
+<? endif ?>
diff --git a/app/views/lti/tool/index.php b/app/views/lti/tool/index.php
new file mode 100644
index 00000000000..c5254400584
--- /dev/null
+++ b/app/views/lti/tool/index.php
@@ -0,0 +1,7 @@
+<?php
+/**
+ * @var LtiTool $tool
+ * @var LtiDeployment $deployment
+ */
+?>
+<?= $this->render_partial('lti/_tool_info', ['tool' => $tool, 'deployment' => $deployment]) ?>
diff --git a/composer.json b/composer.json
index 7aef546f922..410e14d1cae 100644
--- a/composer.json
+++ b/composer.json
@@ -125,7 +125,12 @@
         "nyholm/psr7-server": "1.1.0",
         "erusev/parsedown": "1.7.4",
         "league/oauth2-client": "2.7.0",
-        "nette/php-generator": "4.1.5"
+        "nette/php-generator": "4.1.5",
+        "oat-sa/lib-lti1p3-core": "^7.1",
+        "oat-sa/lib-lti1p3-ags": "^2.0",
+        "phpseclib/phpseclib2_compat": "^1.0",
+        "oat-sa/lib-lti1p3-deep-linking": "^4.1",
+        "lcobucci/jwt": "^4.3"
     },
     "replace": {
         "symfony/polyfill-php73": "*",
diff --git a/composer.lock b/composer.lock
index 7897e22c5d8..d889e8f02a3 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "5bc2e4ef41517f46c14d02cfecdd95d9",
+    "content-hash": "3a2daa57e8228dca24a4ab68a05bb5a3",
     "packages": [
         {
             "name": "algo26-matthias/idna-convert",
@@ -62,6 +62,135 @@
             },
             "time": "2024-02-14T13:30:05+00:00"
         },
+        {
+            "name": "brick/math",
+            "version": "0.12.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/brick/math.git",
+                "reference": "f510c0a40911935b77b86859eb5223d58d660df1"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/brick/math/zipball/f510c0a40911935b77b86859eb5223d58d660df1",
+                "reference": "f510c0a40911935b77b86859eb5223d58d660df1",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^8.1"
+            },
+            "require-dev": {
+                "php-coveralls/php-coveralls": "^2.2",
+                "phpunit/phpunit": "^10.1",
+                "vimeo/psalm": "5.16.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Brick\\Math\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "Arbitrary-precision arithmetic library",
+            "keywords": [
+                "Arbitrary-precision",
+                "BigInteger",
+                "BigRational",
+                "arithmetic",
+                "bigdecimal",
+                "bignum",
+                "bignumber",
+                "brick",
+                "decimal",
+                "integer",
+                "math",
+                "mathematics",
+                "rational"
+            ],
+            "support": {
+                "issues": "https://github.com/brick/math/issues",
+                "source": "https://github.com/brick/math/tree/0.12.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/BenMorel",
+                    "type": "github"
+                }
+            ],
+            "time": "2023-11-29T23:19:16+00:00"
+        },
+        {
+            "name": "carbonphp/carbon-doctrine-types",
+            "version": "3.2.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git",
+                "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d",
+                "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^8.1"
+            },
+            "conflict": {
+                "doctrine/dbal": "<4.0.0 || >=5.0.0"
+            },
+            "require-dev": {
+                "doctrine/dbal": "^4.0.0",
+                "nesbot/carbon": "^2.71.0 || ^3.0.0",
+                "phpunit/phpunit": "^10.3"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Carbon\\Doctrine\\": "src/Carbon/Doctrine/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "KyleKatarn",
+                    "email": "kylekatarnls@gmail.com"
+                }
+            ],
+            "description": "Types to use Carbon in Doctrine",
+            "keywords": [
+                "carbon",
+                "date",
+                "datetime",
+                "doctrine",
+                "time"
+            ],
+            "support": {
+                "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues",
+                "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/kylekatarnls",
+                    "type": "github"
+                },
+                {
+                    "url": "https://opencollective.com/Carbon",
+                    "type": "open_collective"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-02-09T16:56:22+00:00"
+        },
         {
             "name": "caxy/php-htmldiff",
             "version": "v0.1.15",
@@ -123,6 +252,50 @@
             },
             "time": "2023-11-05T23:49:04+00:00"
         },
+        {
+            "name": "codercat/jwk-to-pem",
+            "version": "1.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/acodercat/php-jwk-to-pem.git",
+                "reference": "4b3cdcf5f87b9b074f132f763a6b7b82c7d3ff1d"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/acodercat/php-jwk-to-pem/zipball/4b3cdcf5f87b9b074f132f763a6b7b82c7d3ff1d",
+                "reference": "4b3cdcf5f87b9b074f132f763a6b7b82c7d3ff1d",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.1",
+                "phpseclib/phpseclib": "^3.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^7.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "CoderCat\\JWKToPEM\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "codercat",
+                    "email": "1067302838@qq.com"
+                }
+            ],
+            "description": "Convert JWK to PEM format.",
+            "support": {
+                "issues": "https://github.com/acodercat/php-jwk-to-pem/issues",
+                "source": "https://github.com/acodercat/php-jwk-to-pem/tree/1.1"
+            },
+            "time": "2021-04-28T07:37:03+00:00"
+        },
         {
             "name": "defuse/php-encryption",
             "version": "v2.4.0",
@@ -1269,38 +1442,39 @@
         },
         {
             "name": "lcobucci/jwt",
-            "version": "5.3.0",
+            "version": "4.3.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/lcobucci/jwt.git",
-                "reference": "08071d8d2c7f4b00222cc4b1fb6aa46990a80f83"
+                "reference": "4d7de2fe0d51a96418c0d04004986e410e87f6b4"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/lcobucci/jwt/zipball/08071d8d2c7f4b00222cc4b1fb6aa46990a80f83",
-                "reference": "08071d8d2c7f4b00222cc4b1fb6aa46990a80f83",
+                "url": "https://api.github.com/repos/lcobucci/jwt/zipball/4d7de2fe0d51a96418c0d04004986e410e87f6b4",
+                "reference": "4d7de2fe0d51a96418c0d04004986e410e87f6b4",
                 "shasum": ""
             },
             "require": {
+                "ext-hash": "*",
+                "ext-json": "*",
+                "ext-mbstring": "*",
                 "ext-openssl": "*",
                 "ext-sodium": "*",
-                "php": "~8.1.0 || ~8.2.0 || ~8.3.0",
-                "psr/clock": "^1.0"
+                "lcobucci/clock": "^2.0 || ^3.0",
+                "php": "^7.4 || ^8.0"
             },
             "require-dev": {
-                "infection/infection": "^0.27.0",
-                "lcobucci/clock": "^3.0",
-                "lcobucci/coding-standard": "^11.0",
-                "phpbench/phpbench": "^1.2.9",
-                "phpstan/extension-installer": "^1.2",
-                "phpstan/phpstan": "^1.10.7",
-                "phpstan/phpstan-deprecation-rules": "^1.1.3",
-                "phpstan/phpstan-phpunit": "^1.3.10",
-                "phpstan/phpstan-strict-rules": "^1.5.0",
-                "phpunit/phpunit": "^10.2.6"
-            },
-            "suggest": {
-                "lcobucci/clock": ">= 3.0"
+                "infection/infection": "^0.21",
+                "lcobucci/coding-standard": "^6.0",
+                "mikey179/vfsstream": "^1.6.7",
+                "phpbench/phpbench": "^1.2",
+                "phpstan/extension-installer": "^1.0",
+                "phpstan/phpstan": "^1.4",
+                "phpstan/phpstan-deprecation-rules": "^1.0",
+                "phpstan/phpstan-phpunit": "^1.0",
+                "phpstan/phpstan-strict-rules": "^1.0",
+                "phpunit/php-invoker": "^3.1",
+                "phpunit/phpunit": "^9.5"
             },
             "type": "library",
             "autoload": {
@@ -1326,7 +1500,7 @@
             ],
             "support": {
                 "issues": "https://github.com/lcobucci/jwt/issues",
-                "source": "https://github.com/lcobucci/jwt/tree/5.3.0"
+                "source": "https://github.com/lcobucci/jwt/tree/4.3.0"
             },
             "funding": [
                 {
@@ -1338,7 +1512,7 @@
                     "type": "patreon"
                 }
             ],
-            "time": "2024-04-11T23:07:54+00:00"
+            "time": "2023-01-02T13:28:00+00:00"
         },
         {
             "name": "league/event",
@@ -2076,6 +2250,112 @@
             },
             "time": "2022-11-28T03:29:06+00:00"
         },
+        {
+            "name": "nesbot/carbon",
+            "version": "3.8.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/briannesbitt/Carbon.git",
+                "reference": "e1268cdbc486d97ce23fef2c666dc3c6b6de9947"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/e1268cdbc486d97ce23fef2c666dc3c6b6de9947",
+                "reference": "e1268cdbc486d97ce23fef2c666dc3c6b6de9947",
+                "shasum": ""
+            },
+            "require": {
+                "carbonphp/carbon-doctrine-types": "<100.0",
+                "ext-json": "*",
+                "php": "^8.1",
+                "psr/clock": "^1.0",
+                "symfony/clock": "^6.3 || ^7.0",
+                "symfony/polyfill-mbstring": "^1.0",
+                "symfony/translation": "^4.4.18 || ^5.2.1|| ^6.0 || ^7.0"
+            },
+            "provide": {
+                "psr/clock-implementation": "1.0"
+            },
+            "require-dev": {
+                "doctrine/dbal": "^3.6.3 || ^4.0",
+                "doctrine/orm": "^2.15.2 || ^3.0",
+                "friendsofphp/php-cs-fixer": "^3.57.2",
+                "kylekatarnls/multi-tester": "^2.5.3",
+                "ondrejmirtes/better-reflection": "^6.25.0.4",
+                "phpmd/phpmd": "^2.15.0",
+                "phpstan/extension-installer": "^1.3.1",
+                "phpstan/phpstan": "^1.11.2",
+                "phpunit/phpunit": "^10.5.20",
+                "squizlabs/php_codesniffer": "^3.9.0"
+            },
+            "bin": [
+                "bin/carbon"
+            ],
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.x-dev",
+                    "dev-2.x": "2.x-dev"
+                },
+                "laravel": {
+                    "providers": [
+                        "Carbon\\Laravel\\ServiceProvider"
+                    ]
+                },
+                "phpstan": {
+                    "includes": [
+                        "extension.neon"
+                    ]
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Carbon\\": "src/Carbon/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Brian Nesbitt",
+                    "email": "brian@nesbot.com",
+                    "homepage": "https://markido.com"
+                },
+                {
+                    "name": "kylekatarnls",
+                    "homepage": "https://github.com/kylekatarnls"
+                }
+            ],
+            "description": "An API extension for DateTime that supports 281 different languages.",
+            "homepage": "https://carbon.nesbot.com",
+            "keywords": [
+                "date",
+                "datetime",
+                "time"
+            ],
+            "support": {
+                "docs": "https://carbon.nesbot.com/docs",
+                "issues": "https://github.com/briannesbitt/Carbon/issues",
+                "source": "https://github.com/briannesbitt/Carbon"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sponsors/kylekatarnls",
+                    "type": "github"
+                },
+                {
+                    "url": "https://opencollective.com/Carbon#sponsor",
+                    "type": "opencollective"
+                },
+                {
+                    "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-11-07T17:46:48+00:00"
+        },
         {
             "name": "nette/php-generator",
             "version": "v4.1.5",
@@ -2484,108 +2764,250 @@
             "time": "2023-11-08T09:30:43+00:00"
         },
         {
-            "name": "okvpn/clock-lts",
-            "version": "1.0.0",
+            "name": "oat-sa/lib-lti1p3-ags",
+            "version": "2.0.2",
             "source": {
                 "type": "git",
-                "url": "https://github.com/okvpn/clock-lts.git",
-                "reference": "5e7dc00d23a7d65e8ed2b0ff7a4dcf7860c05a14"
+                "url": "https://github.com/oat-sa/lib-lti1p3-ags.git",
+                "reference": "3967915ab99e1a01b8445aef94bae6e79ff3dac0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/okvpn/clock-lts/zipball/5e7dc00d23a7d65e8ed2b0ff7a4dcf7860c05a14",
-                "reference": "5e7dc00d23a7d65e8ed2b0ff7a4dcf7860c05a14",
+                "url": "https://api.github.com/repos/oat-sa/lib-lti1p3-ags/zipball/3967915ab99e1a01b8445aef94bae6e79ff3dac0",
+                "reference": "3967915ab99e1a01b8445aef94bae6e79ff3dac0",
                 "shasum": ""
             },
             "require": {
-                "php": ">=7.2",
-                "psr/clock": "^1.0"
-            },
-            "provide": {
-                "psr/clock-implementation": "1.0"
-            },
-            "replace": {
-                "lcobucci/clock": "*"
+                "ext-json": "*",
+                "nesbot/carbon": "^2.72 || ^3.0",
+                "nyholm/psr7": "^1.8",
+                "oat-sa/lib-lti1p3-core": "^7.0",
+                "php": ">=8.0.0",
+                "psr/http-message": "^1.1 || ^2.0"
             },
             "require-dev": {
-                "infection/infection": "^0.27",
-                "lcobucci/coding-standard": "^11.0.0",
-                "phpstan/extension-installer": "^1.3.1",
-                "phpstan/phpstan": "^1.10.25",
-                "phpstan/phpstan-deprecation-rules": "^1.1.3",
-                "phpstan/phpstan-phpunit": "^1.3.13",
-                "phpstan/phpstan-strict-rules": "^1.5.1",
-                "phpunit/phpunit": "^10.2.3"
+                "php-coveralls/php-coveralls": "^2.4",
+                "phpunit/phpunit": "^9.6",
+                "psalm/plugin-phpunit": "^0.15.1",
+                "vimeo/psalm": "^4.6"
             },
             "type": "library",
             "autoload": {
                 "psr-4": {
-                    "Lcobucci\\Clock\\": "src"
+                    "OAT\\Library\\Lti1p3Ags\\": "src/"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Luís Cobucci",
-                    "email": "lcobucci@gmail.com"
-                },
-                {
-                    "name": "UÅ‚adzimir Tsykun",
-                    "email": "vtsykun@okvpn.org"
-                }
+                "GPL-2.0-only"
             ],
-            "description": "Adding support for a wider range of PHP versions to lcobucci/clock",
+            "description": "OAT LTI 1.3 Advantage AGS Library",
             "support": {
-                "source": "https://github.com/okvpn/clock-lts/tree/1.0.0"
+                "issues": "https://github.com/oat-sa/lib-lti1p3-ags/issues",
+                "source": "https://github.com/oat-sa/lib-lti1p3-ags/tree/2.0.2"
             },
-            "time": "2023-09-26T11:13:49+00:00"
+            "time": "2024-03-09T20:25:26+00:00"
         },
         {
-            "name": "opis/json-schema",
-            "version": "2.3.0",
+            "name": "oat-sa/lib-lti1p3-core",
+            "version": "7.1.0",
             "source": {
                 "type": "git",
-                "url": "https://github.com/opis/json-schema.git",
-                "reference": "c48df6d7089a45f01e1c82432348f2d5976f9bfb"
+                "url": "https://github.com/oat-sa/lib-lti1p3-core.git",
+                "reference": "39dbc6880734f99ca41488e5ebcd5b6ad8fc26f4"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/opis/json-schema/zipball/c48df6d7089a45f01e1c82432348f2d5976f9bfb",
-                "reference": "c48df6d7089a45f01e1c82432348f2d5976f9bfb",
+                "url": "https://api.github.com/repos/oat-sa/lib-lti1p3-core/zipball/39dbc6880734f99ca41488e5ebcd5b6ad8fc26f4",
+                "reference": "39dbc6880734f99ca41488e5ebcd5b6ad8fc26f4",
                 "shasum": ""
             },
             "require": {
+                "codercat/jwk-to-pem": "^1.1",
                 "ext-json": "*",
-                "opis/string": "^2.0",
-                "opis/uri": "^1.0",
-                "php": "^7.4 || ^8.0"
+                "ext-openssl": "*",
+                "guzzlehttp/guzzle": "^7.8",
+                "lcobucci/jwt": "^4.3",
+                "league/oauth2-server": "^8.5",
+                "nesbot/carbon": "^2.72 || ^3.0",
+                "nyholm/psr7": "^1.8",
+                "php": ">=8.0.0",
+                "phpseclib/phpseclib": "^3.0.37",
+                "psr/cache": "^2.0 || ^3.0",
+                "psr/http-message": "^1.1 || ^2.0",
+                "psr/http-server-handler": "^1.0",
+                "psr/log": "^1.0 || ^2.0 || ^3.0",
+                "ramsey/uuid": "^3.9 || ^4.7"
             },
             "require-dev": {
-                "ext-bcmath": "*",
-                "ext-intl": "*",
-                "phpunit/phpunit": "^9.0"
+                "cache/array-adapter": "^1.2",
+                "php-coveralls/php-coveralls": "^2.7",
+                "phpunit/phpunit": "^9.6",
+                "psalm/plugin-phpunit": "^0.15",
+                "vimeo/psalm": "^4.30"
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "2.x-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
-                    "Opis\\JsonSchema\\": "src/"
+                    "OAT\\Library\\Lti1p3Core\\": "src/"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
-                "Apache-2.0"
+                "GPL-2.0-only"
             ],
-            "authors": [
-                {
-                    "name": "Sorin Sarca",
+            "description": "OAT LTI 1.3 Core Library",
+            "support": {
+                "issues": "https://github.com/oat-sa/lib-lti1p3-core/issues",
+                "source": "https://github.com/oat-sa/lib-lti1p3-core/tree/7.1.0"
+            },
+            "time": "2024-11-29T11:31:29+00:00"
+        },
+        {
+            "name": "oat-sa/lib-lti1p3-deep-linking",
+            "version": "4.1.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/oat-sa/lib-lti1p3-deep-linking.git",
+                "reference": "7e11d03b690fd2a726edd29d3002ee0777985bc1"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/oat-sa/lib-lti1p3-deep-linking/zipball/7e11d03b690fd2a726edd29d3002ee0777985bc1",
+                "reference": "7e11d03b690fd2a726edd29d3002ee0777985bc1",
+                "shasum": ""
+            },
+            "require": {
+                "ext-json": "*",
+                "oat-sa/lib-lti1p3-core": "^7.0",
+                "php": ">=8.0.0"
+            },
+            "require-dev": {
+                "php-coveralls/php-coveralls": "^2.4",
+                "phpunit/phpunit": "^9.6",
+                "psalm/plugin-phpunit": "^0.15",
+                "vimeo/psalm": "^4.6"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "OAT\\Library\\Lti1p3DeepLinking\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "GPL-2.0-only"
+            ],
+            "description": "OAT LTI 1.3 Deep Linking Library",
+            "support": {
+                "issues": "https://github.com/oat-sa/lib-lti1p3-deep-linking/issues",
+                "source": "https://github.com/oat-sa/lib-lti1p3-deep-linking/tree/4.1.0"
+            },
+            "time": "2023-12-22T08:22:05+00:00"
+        },
+        {
+            "name": "okvpn/clock-lts",
+            "version": "1.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/okvpn/clock-lts.git",
+                "reference": "5e7dc00d23a7d65e8ed2b0ff7a4dcf7860c05a14"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/okvpn/clock-lts/zipball/5e7dc00d23a7d65e8ed2b0ff7a4dcf7860c05a14",
+                "reference": "5e7dc00d23a7d65e8ed2b0ff7a4dcf7860c05a14",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2",
+                "psr/clock": "^1.0"
+            },
+            "provide": {
+                "psr/clock-implementation": "1.0"
+            },
+            "replace": {
+                "lcobucci/clock": "*"
+            },
+            "require-dev": {
+                "infection/infection": "^0.27",
+                "lcobucci/coding-standard": "^11.0.0",
+                "phpstan/extension-installer": "^1.3.1",
+                "phpstan/phpstan": "^1.10.25",
+                "phpstan/phpstan-deprecation-rules": "^1.1.3",
+                "phpstan/phpstan-phpunit": "^1.3.13",
+                "phpstan/phpstan-strict-rules": "^1.5.1",
+                "phpunit/phpunit": "^10.2.3"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Lcobucci\\Clock\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Luís Cobucci",
+                    "email": "lcobucci@gmail.com"
+                },
+                {
+                    "name": "UÅ‚adzimir Tsykun",
+                    "email": "vtsykun@okvpn.org"
+                }
+            ],
+            "description": "Adding support for a wider range of PHP versions to lcobucci/clock",
+            "support": {
+                "source": "https://github.com/okvpn/clock-lts/tree/1.0.0"
+            },
+            "time": "2023-09-26T11:13:49+00:00"
+        },
+        {
+            "name": "opis/json-schema",
+            "version": "2.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/opis/json-schema.git",
+                "reference": "c48df6d7089a45f01e1c82432348f2d5976f9bfb"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/opis/json-schema/zipball/c48df6d7089a45f01e1c82432348f2d5976f9bfb",
+                "reference": "c48df6d7089a45f01e1c82432348f2d5976f9bfb",
+                "shasum": ""
+            },
+            "require": {
+                "ext-json": "*",
+                "opis/string": "^2.0",
+                "opis/uri": "^1.0",
+                "php": "^7.4 || ^8.0"
+            },
+            "require-dev": {
+                "ext-bcmath": "*",
+                "ext-intl": "*",
+                "phpunit/phpunit": "^9.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Opis\\JsonSchema\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "Apache-2.0"
+            ],
+            "authors": [
+                {
+                    "name": "Sorin Sarca",
                     "email": "sarca_sorin@hotmail.com"
                 },
                 {
@@ -3267,6 +3689,54 @@
             ],
             "time": "2024-03-03T02:14:58+00:00"
         },
+        {
+            "name": "phpseclib/phpseclib2_compat",
+            "version": "1.0.6",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phpseclib/phpseclib2_compat.git",
+                "reference": "90976f25d6c2ff936878624b9cfaa322db11dde7"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phpseclib/phpseclib2_compat/zipball/90976f25d6c2ff936878624b9cfaa322db11dde7",
+                "reference": "90976f25d6c2ff936878624b9cfaa322db11dde7",
+                "shasum": ""
+            },
+            "require": {
+                "phpseclib/phpseclib": "^3.0"
+            },
+            "provide": {
+                "phpseclib/phpseclib": "2.0.47"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^5.7|^6.0|^9.4"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "phpseclib\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jim Wigginton",
+                    "email": "terrafrost@php.net",
+                    "role": "Lead Developer"
+                }
+            ],
+            "description": "phpseclib 2.0 polyfill built with phpseclib 3.0",
+            "homepage": "https://github.com/phpseclib/phpseclib2_compat",
+            "support": {
+                "issues": "https://github.com/phpseclib/phpseclib2_compat/issues",
+                "source": "https://github.com/phpseclib/phpseclib2_compat"
+            },
+            "time": "2024-02-26T14:37:15+00:00"
+        },
         {
             "name": "psr/cache",
             "version": "3.0.0",
@@ -3914,6 +4384,187 @@
             },
             "time": "2019-03-08T08:55:37+00:00"
         },
+        {
+            "name": "ramsey/collection",
+            "version": "2.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/ramsey/collection.git",
+                "reference": "a4b48764bfbb8f3a6a4d1aeb1a35bb5e9ecac4a5"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/ramsey/collection/zipball/a4b48764bfbb8f3a6a4d1aeb1a35bb5e9ecac4a5",
+                "reference": "a4b48764bfbb8f3a6a4d1aeb1a35bb5e9ecac4a5",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^8.1"
+            },
+            "require-dev": {
+                "captainhook/plugin-composer": "^5.3",
+                "ergebnis/composer-normalize": "^2.28.3",
+                "fakerphp/faker": "^1.21",
+                "hamcrest/hamcrest-php": "^2.0",
+                "jangregor/phpstan-prophecy": "^1.0",
+                "mockery/mockery": "^1.5",
+                "php-parallel-lint/php-console-highlighter": "^1.0",
+                "php-parallel-lint/php-parallel-lint": "^1.3",
+                "phpcsstandards/phpcsutils": "^1.0.0-rc1",
+                "phpspec/prophecy-phpunit": "^2.0",
+                "phpstan/extension-installer": "^1.2",
+                "phpstan/phpstan": "^1.9",
+                "phpstan/phpstan-mockery": "^1.1",
+                "phpstan/phpstan-phpunit": "^1.3",
+                "phpunit/phpunit": "^9.5",
+                "psalm/plugin-mockery": "^1.1",
+                "psalm/plugin-phpunit": "^0.18.4",
+                "ramsey/coding-standard": "^2.0.3",
+                "ramsey/conventional-commits": "^1.3",
+                "vimeo/psalm": "^5.4"
+            },
+            "type": "library",
+            "extra": {
+                "captainhook": {
+                    "force-install": true
+                },
+                "ramsey/conventional-commits": {
+                    "configFile": "conventional-commits.json"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Ramsey\\Collection\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Ben Ramsey",
+                    "email": "ben@benramsey.com",
+                    "homepage": "https://benramsey.com"
+                }
+            ],
+            "description": "A PHP library for representing and manipulating collections.",
+            "keywords": [
+                "array",
+                "collection",
+                "hash",
+                "map",
+                "queue",
+                "set"
+            ],
+            "support": {
+                "issues": "https://github.com/ramsey/collection/issues",
+                "source": "https://github.com/ramsey/collection/tree/2.0.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/ramsey",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/ramsey/collection",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2022-12-31T21:50:55+00:00"
+        },
+        {
+            "name": "ramsey/uuid",
+            "version": "4.7.6",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/ramsey/uuid.git",
+                "reference": "91039bc1faa45ba123c4328958e620d382ec7088"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/ramsey/uuid/zipball/91039bc1faa45ba123c4328958e620d382ec7088",
+                "reference": "91039bc1faa45ba123c4328958e620d382ec7088",
+                "shasum": ""
+            },
+            "require": {
+                "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12",
+                "ext-json": "*",
+                "php": "^8.0",
+                "ramsey/collection": "^1.2 || ^2.0"
+            },
+            "replace": {
+                "rhumsaa/uuid": "self.version"
+            },
+            "require-dev": {
+                "captainhook/captainhook": "^5.10",
+                "captainhook/plugin-composer": "^5.3",
+                "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0",
+                "doctrine/annotations": "^1.8",
+                "ergebnis/composer-normalize": "^2.15",
+                "mockery/mockery": "^1.3",
+                "paragonie/random-lib": "^2",
+                "php-mock/php-mock": "^2.2",
+                "php-mock/php-mock-mockery": "^1.3",
+                "php-parallel-lint/php-parallel-lint": "^1.1",
+                "phpbench/phpbench": "^1.0",
+                "phpstan/extension-installer": "^1.1",
+                "phpstan/phpstan": "^1.8",
+                "phpstan/phpstan-mockery": "^1.1",
+                "phpstan/phpstan-phpunit": "^1.1",
+                "phpunit/phpunit": "^8.5 || ^9",
+                "ramsey/composer-repl": "^1.4",
+                "slevomat/coding-standard": "^8.4",
+                "squizlabs/php_codesniffer": "^3.5",
+                "vimeo/psalm": "^4.9"
+            },
+            "suggest": {
+                "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.",
+                "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.",
+                "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.",
+                "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter",
+                "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type."
+            },
+            "type": "library",
+            "extra": {
+                "captainhook": {
+                    "force-install": true
+                }
+            },
+            "autoload": {
+                "files": [
+                    "src/functions.php"
+                ],
+                "psr-4": {
+                    "Ramsey\\Uuid\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).",
+            "keywords": [
+                "guid",
+                "identifier",
+                "uuid"
+            ],
+            "support": {
+                "issues": "https://github.com/ramsey/uuid/issues",
+                "source": "https://github.com/ramsey/uuid/tree/4.7.6"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/ramsey",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/ramsey/uuid",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-04-27T21:32:50+00:00"
+        },
         {
             "name": "scssphp/scssphp",
             "version": "v1.12.1",
@@ -4189,6 +4840,80 @@
             ],
             "time": "2024-04-15T07:35:15+00:00"
         },
+        {
+            "name": "symfony/clock",
+            "version": "v6.4.13",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/clock.git",
+                "reference": "b2bf55c4dd115003309eafa87ee7df9ed3dde81b"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/clock/zipball/b2bf55c4dd115003309eafa87ee7df9ed3dde81b",
+                "reference": "b2bf55c4dd115003309eafa87ee7df9ed3dde81b",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1",
+                "psr/clock": "^1.0",
+                "symfony/polyfill-php83": "^1.28"
+            },
+            "provide": {
+                "psr/clock-implementation": "1.0"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "Resources/now.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Component\\Clock\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Decouples applications from the system clock",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "clock",
+                "psr20",
+                "time"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/clock/tree/v6.4.13"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-09-25T14:18:03+00:00"
+        },
         {
             "name": "symfony/console",
             "version": "v6.4.7",
@@ -5046,6 +5771,179 @@
             ],
             "time": "2024-05-31T14:49:08+00:00"
         },
+        {
+            "name": "symfony/translation",
+            "version": "v6.4.13",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/translation.git",
+                "reference": "bee9bfabfa8b4045a66bf82520e492cddbaffa66"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/translation/zipball/bee9bfabfa8b4045a66bf82520e492cddbaffa66",
+                "reference": "bee9bfabfa8b4045a66bf82520e492cddbaffa66",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1",
+                "symfony/deprecation-contracts": "^2.5|^3",
+                "symfony/polyfill-mbstring": "~1.0",
+                "symfony/translation-contracts": "^2.5|^3.0"
+            },
+            "conflict": {
+                "symfony/config": "<5.4",
+                "symfony/console": "<5.4",
+                "symfony/dependency-injection": "<5.4",
+                "symfony/http-client-contracts": "<2.5",
+                "symfony/http-kernel": "<5.4",
+                "symfony/service-contracts": "<2.5",
+                "symfony/twig-bundle": "<5.4",
+                "symfony/yaml": "<5.4"
+            },
+            "provide": {
+                "symfony/translation-implementation": "2.3|3.0"
+            },
+            "require-dev": {
+                "nikic/php-parser": "^4.18|^5.0",
+                "psr/log": "^1|^2|^3",
+                "symfony/config": "^5.4|^6.0|^7.0",
+                "symfony/console": "^5.4|^6.0|^7.0",
+                "symfony/dependency-injection": "^5.4|^6.0|^7.0",
+                "symfony/finder": "^5.4|^6.0|^7.0",
+                "symfony/http-client-contracts": "^2.5|^3.0",
+                "symfony/http-kernel": "^5.4|^6.0|^7.0",
+                "symfony/intl": "^5.4|^6.0|^7.0",
+                "symfony/polyfill-intl-icu": "^1.21",
+                "symfony/routing": "^5.4|^6.0|^7.0",
+                "symfony/service-contracts": "^2.5|^3",
+                "symfony/yaml": "^5.4|^6.0|^7.0"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "Resources/functions.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Component\\Translation\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Provides tools to internationalize your application",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/translation/tree/v6.4.13"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-09-27T18:14:25+00:00"
+        },
+        {
+            "name": "symfony/translation-contracts",
+            "version": "v3.5.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/translation-contracts.git",
+                "reference": "4667ff3bd513750603a09c8dedbea942487fb07c"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/4667ff3bd513750603a09c8dedbea942487fb07c",
+                "reference": "4667ff3bd513750603a09c8dedbea942487fb07c",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "3.5-dev"
+                },
+                "thanks": {
+                    "name": "symfony/contracts",
+                    "url": "https://github.com/symfony/contracts"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Contracts\\Translation\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Test/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Generic abstractions related to translation",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "abstractions",
+                "contracts",
+                "decoupling",
+                "interfaces",
+                "interoperability",
+                "standards"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/translation-contracts/tree/v3.5.1"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-09-25T14:20:29+00:00"
+        },
         {
             "name": "symfony/var-dumper",
             "version": "v6.4.7",
@@ -8745,9 +9643,9 @@
         "ext-dom": "*",
         "ext-iconv": "*"
     },
-    "platform-dev": {},
+    "platform-dev": [],
     "platform-overrides": {
         "php": "8.1"
     },
-    "plugin-api-version": "2.6.0"
+    "plugin-api-version": "2.3.0"
 }
diff --git a/db/migrations/6.0.39_add_lti13a.php b/db/migrations/6.0.39_add_lti13a.php
new file mode 100644
index 00000000000..3aac9638f99
--- /dev/null
+++ b/db/migrations/6.0.39_add_lti13a.php
@@ -0,0 +1,235 @@
+<?php
+
+
+class AddLti13a extends Migration
+{
+    public function description()
+    {
+        return 'Add tables and settings for the LTI 1.3A functionality.';
+    }
+
+    protected function up()
+    {
+        $db = DBManager::get();
+
+        $db->exec(
+            "CREATE TABLE IF NOT EXISTS keyrings (
+                id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+                range_id CHAR(32) COLLATE latin1_bin NOT NULL,
+                range_type VARCHAR(16) NOT NULL,
+                public_key BLOB(16384) NOT NULL,
+                private_key BLOB(16384) NOT NULL DEFAULT '',
+                passphrase VARCHAR(512) NOT NULL DEFAULT '',
+                mkdate INT(11) NOT NULL DEFAULT 0,
+                chdate INT(11) NOT NULL DEFAULT 0
+            )"
+        );
+        $db->exec("ALTER TABLE `keyrings` ADD INDEX(`range_id`, `range_type`)");
+
+        $db->exec("RENAME TABLE `lti_tool` TO lti_tools");
+
+        $db->exec(
+            "ALTER TABLE `lti_tools`
+            ADD COLUMN lti_version VARCHAR(8) NOT NULL DEFAULT '1.3a',
+            ADD COLUMN range_id CHAR(32) COLLATE latin1_bin NOT NULL,
+            ADD COLUMN oidc_init_url VARCHAR(255) NOT NULL DEFAULT '',
+            ADD COLUMN oauth2_client_id INT NULL DEFAULT NULL,
+            ADD COLUMN jwks_url VARCHAR(255) NOT NULL DEFAULT '',
+            ADD COLUMN jwks_key_id VARCHAR(255) NOT NULL DEFAULT '',
+            ADD COLUMN deep_linking_url VARCHAR(255) NOT NULL DEFAULT '',
+            ADD COLUMN terms_of_use_url VARCHAR(255) NOT NULL DEFAULT '',
+            ADD COLUMN privacy_policy_url VARCHAR(255) NOT NULL DEFAULT '',
+            ADD COLUMN data_protection_notes TEXT DEFAULT NULL"
+        );
+
+        $this->migrateLtiDataTable();
+
+        $this->addConfig();
+
+        $this->migrateLtiToolTitle();
+    }
+
+    protected function migrateLtiDataTable()
+    {
+        $db = DBManager::get();
+        $db->exec("RENAME TABLE `lti_data` TO lti_deployments");
+
+        //Create LTI tool instances for the old LTI 1.0/1.1 tools
+        //that have been configured directly in a course:
+        $stmt = $db->prepare(
+            "SELECT `id`, `tool_id`, `title`, `options`
+            FROM `lti_deployments`
+            WHERE `tool_id` = '0'"
+        );
+        $update_stmt = $db->prepare(
+            "UPDATE `lti_deployments`
+            SET `tool_id` = :new_tool_id,
+            `options` = :new_options
+            WHERE `id` = :deployment_id"
+        );
+        $create_tool_stmt = $db->prepare(
+            "INSERT INTO `lti_tools`
+            (`id`, `name`, `launch_url`, `consumer_key`, `consumer_secret`,
+            `custom_parameters`, `send_lis_person`, `lti_version`, `is_global`,
+            `mkdate`, `chdate`)
+            VALUES
+            (:id, :name, :launch_url, :consumer_key, :consumer_secret,
+            :custom_parameters, :send_lis_person, '1.1', '0',
+            UNIX_TIMESTAMP(), UNIX_TIMESTAMP())"
+        );
+        $new_tool_id_stmt = $db->prepare("SELECT MAX(`id`) + 1 FROM `lti_tools`");
+        $stmt->execute();
+        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
+            if (empty($row['id']) || empty($row['title']) || empty($row['options'])) {
+                //That tool cannot be migrated.
+                continue;
+            }
+            //Create a new tool and migrate the data from the options field:
+            $options = json_decode($row['options'], true);
+            $new_tool_id_stmt->execute();
+            $new_tool_id = $new_tool_id_stmt->fetchColumn();
+            $success = $create_tool_stmt->execute([
+                'id'           => $new_tool_id,
+                'name'         => $row['title'],
+                'launch_url'   => $options['launch_url'] ?? '',
+                'consumer_key' => $options['consumer_key'] ?? '',
+                'consumer_secret' => $options['consumer_secret'] ?? '',
+                'custom_parameters' => $options['custom_parameters'] ?? '',
+                'send_lis_person'   => $options['send_lis_person'] ?? '0',
+            ]);
+            unset(
+                $options['launch_url'],
+                $options['consumer_key'],
+                $options['consumer_secret'],
+                $options['custom_parameters'],
+                $options['send_lis_person']
+            );
+            if ($success) {
+                $update_stmt->execute([
+                    'new_tool_id'   => $new_tool_id,
+                    'new_options'   => json_encode($options ?? []),
+                    'deployment_id' => $row['id']
+                ]);
+            }
+        }
+
+        $db->exec(
+            "CREATE TABLE IF NOT EXISTS lti_tool_privacy_settings (
+                tool_id INT(11) NOT NULL,
+                user_id CHAR(32) COLLATE latin1_bin NOT NULL,
+                accepted TINYINT(1) NOT NULL DEFAULT 0,
+                allowed_optional_fields VARCHAR(256) NOT NULL DEFAULT '',
+                mkdate INT(11) NOT NULL DEFAULT 0,
+                chdate INT(11) NOT NULL DEFAULT 0,
+                PRIMARY KEY (tool_id, user_id)
+            )"
+        );
+    }
+
+    protected function addConfig()
+    {
+        $db = DBManager::get();
+
+        $configs = [
+            [
+                'LTI_DATA_PROTECTION_DEFAULT_WARNING',
+                'Bitte beachten Sie die Datenschutzhinweise. Wenn Sie zugestimmt haben, werden Ihre Daten weitergegeben.',
+                'string',
+                'global',
+                'Eine Warnung zur Weitergabe personenbezogener Daten, die standardmäßig angezeigt wird, wenn Personen aus einer Veranstaltung in ein LTI-Tool wechseln.'
+            ],
+            [
+                'LTI_DATA_PROTECTION_COURSE_WARNING',
+                '',
+                'string',
+                'course',
+                'Eine in einer Veranstaltung angepasste Warnung zur Weitergabe personenbezogener Daten, die angezeigt wird, wenn Personen aus der Veranstaltung in ein LTI-Tool wechseln.'
+            ],
+            [
+                'LTI_ALLOW_TOOL_CONFIG_IN_COURSE',
+                '1',
+                'boolean',
+                'global',
+                'Soll es Lehrenden möglich sein, eigene LTI-Tools zu konfigurieren? Wenn nicht, können nur global konfigurierte LTI-Tools in Veranstaltungen angebunden werden.'
+            ]
+        ];
+
+        $stmt = $db->prepare(
+            "INSERT INTO `config`
+            (`field`, `value`, `type`, `range`, `description`, `section`, `mkdate`, `chdate`)
+            VALUES
+            (:field, :value, :type, :range, :description, 'LTI', UNIX_TIMESTAMP(), UNIX_TIMESTAMP())"
+        );
+
+        foreach ($configs as $c) {
+            $stmt->execute([
+                'field'       => $c[0],
+                'value'       => $c[1],
+                'type'        => $c[2],
+                'range'       => $c[3],
+                'description' => $c[4],
+            ]);
+        }
+    }
+
+    protected function migrateLtiToolTitle()
+    {
+        $db = DBManager::get();
+        $plugin_id = $db->query("SELECT `pluginid` FROM `plugins` WHERE `pluginclassname` = 'LtiToolModule'")->fetchColumn();
+        if ($plugin_id === false) {
+            //The LTI core module is not registered. We cannot continue.
+            return;
+        }
+
+        $fetch_stmt = $db->prepare("SELECT `range_id`, `value` FROM `config_values` where `field` = 'LTI_TOOL_TITLE'");
+        $get_tool_metadata_stmt = $db->prepare(
+            "SELECT `metadata` FROM `tools_activated`
+             WHERE `range_type` = 'course' AND `plugin_id` = :plugin_id AND `range_id` = :range_id"
+        );
+        $update_tool_stmt = $db->prepare(
+            "UPDATE `tools_activated`
+             SET `metadata` = :metadata,
+            `chdate` = UNIX_TIMESTAMP()
+             WHERE
+             `range_type` = 'course'
+             AND `range_id` = :range_id
+             AND `plugin_id` = :plugin_id"
+        );
+
+        $fetch_stmt->execute();
+        while ($row = $fetch_stmt->fetch()) {
+            $get_tool_metadata_stmt->execute(['plugin_id' => $plugin_id, 'range_id' => $row['range_id']]);
+            $metadata_json = $get_tool_metadata_stmt->fetchColumn();
+            if ($metadata_json === false) {
+                //Tool not activated, therefore, nothing needs to be done.
+                continue;
+            }
+            $metadata = [];
+            if ($metadata_json) {
+                //Decode the JSON to get an array that can be modified:
+                $metadata = json_decode($metadata_json, true);
+            }
+            if (!$metadata) {
+                //In case the decoding did not work or there is nothing to decode, create a new array:
+                $metadata = [];
+            }
+            $metadata['displayname'] = $row['value'];
+
+            $update_tool_stmt->execute([
+                'range_id'  => $row['range_id'],
+                'plugin_id' => $plugin_id,
+                'metadata'  => json_encode($metadata)
+            ]);
+        }
+
+        //At this point, all entries from LTI_TOOL_TITLE have been migrated so that that configuration
+        //can be removed:
+        $db->exec("DELETE FROM `config_values` where `field` = 'LTI_TOOL_TITLE'");
+        $db->exec("DELETE FROM `config` WHERE `field` = 'LTI_TOOL_TITLE'");
+    }
+
+    protected function down()
+    {
+        //Uhhh... no!
+    }
+}
diff --git a/db/studip_default_data.sql b/db/studip_default_data.sql
index 511f124fccf..12ab9b58e65 100644
--- a/db/studip_default_data.sql
+++ b/db/studip_default_data.sql
@@ -249,7 +249,6 @@ INSERT INTO `config` (`field`, `value`, `type`, `range`, `section`, `mkdate`, `c
 INSERT INTO `config` (`field`, `value`, `type`, `range`, `section`, `mkdate`, `chdate`, `description`) VALUES('LOGIN_FAQ_TITLE', 'Hinweise zum Login', 'i18n', 'global', 'Loginseite', 1716385357, 1716385357, 'Überschrift für den FAQ-Bereich auf der Loginseite');
 INSERT INTO `config` (`field`, `value`, `type`, `range`, `section`, `mkdate`, `chdate`, `description`) VALUES('LOGIN_FAQ_VISIBILITY', '1', 'boolean', 'global', 'Loginseite', 1716385357, 1716385357, 'Soll der FAQ-Bereich auf der Loginseite sichtbar sein?');
 INSERT INTO `config` (`field`, `value`, `type`, `range`, `section`, `mkdate`, `chdate`, `description`) VALUES('LOG_ENABLE', '1', 'boolean', 'global', 'modules', 1293118059, 1293118059, 'Schaltet ein oder aus, ob das Log global verfügbar ist.');
-INSERT INTO `config` (`field`, `value`, `type`, `range`, `section`, `mkdate`, `chdate`, `description`) VALUES('LTI_TOOL_TITLE', 'LTI-Tool', 'string', 'course', '', 1557244743, 1557244743, 'Voreinstellung für den Titel des Reiters \"LTI-Tool\" im Kurs.');
 INSERT INTO `config` (`field`, `value`, `type`, `range`, `section`, `mkdate`, `chdate`, `description`) VALUES('MAILQUEUE_ENABLE', '0', 'boolean', 'global', 'global', 1403258017, 1403258017, 'Aktiviert bzw. deaktiviert die Mailqueue');
 INSERT INTO `config` (`field`, `value`, `type`, `range`, `section`, `mkdate`, `chdate`, `description`) VALUES('MAILQUEUE_SEND_LIMIT', '0', 'integer', 'global', 'global', 1462287310, 1462287310, 'Wieviele Mails soll die Mailqueue maximal auf einmal an den Mailserver schicken. 0 für unendlich viele.');
 INSERT INTO `config` (`field`, `value`, `type`, `range`, `section`, `mkdate`, `chdate`, `description`) VALUES('MAIL_AS_HTML', '0', 'boolean', 'user', '', 1293118060, 1293118060, 'Benachrichtigungen werden im HTML-Format versandt');
diff --git a/lib/classes/LTI13a/Identity.php b/lib/classes/LTI13a/Identity.php
new file mode 100644
index 00000000000..238ad926e18
--- /dev/null
+++ b/lib/classes/LTI13a/Identity.php
@@ -0,0 +1,103 @@
+<?php
+
+namespace Studip\LTI13a;
+
+use OAT\Library\Lti1p3Core\Message\Payload\MessagePayloadInterface;
+use OAT\Library\Lti1p3Core\User\UserIdentityInterface;
+use OAT\Library\Lti1p3Core\Util\Collection\CollectionInterface;
+
+
+class Identity implements UserIdentityInterface
+{
+    protected \User $user;
+
+    protected array $allowed_optional_fields = [];
+
+    public function __construct(\User $user, \LtiTool $tool)
+    {
+        $this->user = $user;
+
+        $privacy_settings = \LtiToolPrivacySettings::findOneBySQL(
+            '`tool_id` = :tool_id AND `user_id` = :user_id',
+            ['tool_id' => $tool->id, 'user_id' => $user->id]
+        );
+        if ($privacy_settings) {
+            $this->allowed_optional_fields = explode(',', $privacy_settings->allowed_optional_fields);
+        }
+    }
+
+    #[\Override]
+    public function getIdentifier(): string
+    {
+        return $this->user->id;
+    }
+
+    #[\Override]
+    public function getName(): ?string
+    {
+        return $this->user->getFullName();
+    }
+
+    #[\Override]
+    public function getEmail(): ?string
+    {
+        return $this->user->email;
+    }
+
+    #[\Override]
+    public function getGivenName(): ?string
+    {
+        return $this->user->vorname;
+    }
+
+    #[\Override]
+    public function getFamilyName(): ?string
+    {
+        return $this->user->nachname;
+    }
+
+    #[\Override]
+    public function getMiddleName(): ?string
+    {
+        return '';
+    }
+
+    #[\Override]
+    public function getLocale(): ?string
+    {
+        if (!in_array('lang', $this->allowed_optional_fields)) {
+            return '';
+        }
+        return $this->user->preferred_language;
+    }
+
+    #[\Override]
+    public function getPicture(): ?string
+    {
+        if (!in_array('avatar_url', $this->allowed_optional_fields)) {
+            return '';
+        }
+        return \Avatar::getAvatar($this->user->id)->getURL(\Avatar::MEDIUM);
+    }
+
+    #[\Override]
+    public function getAdditionalProperties(): CollectionInterface
+    {
+        return [];
+    }
+
+    #[\Override]
+    public function normalize(): array
+    {
+        return [
+            MessagePayloadInterface::CLAIM_SUB => $this->getIdentifier(),
+            MessagePayloadInterface::CLAIM_USER_NAME => $this->getName(),
+            MessagePayloadInterface::CLAIM_USER_EMAIL => $this->getEmail(),
+            MessagePayloadInterface::CLAIM_USER_GIVEN_NAME => $this->getGivenName(),
+            MessagePayloadInterface::CLAIM_USER_FAMILY_NAME => $this->getFamilyName(),
+            MessagePayloadInterface::CLAIM_USER_MIDDLE_NAME => $this->getMiddleName(),
+            MessagePayloadInterface::CLAIM_USER_LOCALE => $this->getLocale(),
+            MessagePayloadInterface::CLAIM_USER_PICTURE => $this->getPicture()
+        ];
+    }
+}
diff --git a/lib/classes/LTI13a/KeyChainFactory.php b/lib/classes/LTI13a/KeyChainFactory.php
new file mode 100644
index 00000000000..98c88057616
--- /dev/null
+++ b/lib/classes/LTI13a/KeyChainFactory.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Studip\LTI13a;
+
+use OAT\Library\Lti1p3Core\Security\Key\KeyChain;
+use OAT\Library\Lti1p3Core\Security\Key\KeyChainFactoryInterface;
+use OAT\Library\Lti1p3Core\Security\Key\KeyChainInterface;
+use OAT\Library\Lti1p3Core\Security\Key\KeyChainRepositoryInterface;
+use OAT\Library\Lti1p3Core\Security\Key\KeyInterface;
+
+class KeyChainFactory implements KeyChainFactoryInterface
+{
+    /**
+     * @param string $identifier THe ID of the keychain.
+     *
+     * @param string $keySetName The name of the keychain.
+     *
+     * @param mixed $publicKey The public key for the keychain.
+     *
+     * @param mixed $privateKey The private key for the keychain.
+     *
+     * @param string|null $privateKeyPassPhrase The passphrase for the private key.
+     *
+     * @param string $algorithm The algorithm to use.
+     *
+     * @return KeyChainInterface The generated KeyChainInterface instance.
+     *
+     * @throws \Studip\KeyringException In case no keychain can be generated.
+     */
+    public function create(
+        string $identifier,
+        string $keySetName,
+        $publicKey,
+        $privateKey = null,
+        ?string $privateKeyPassPhrase = null,
+        string $algorithm = KeyInterface::ALG_RS256
+    ) : KeyChainInterface
+    {
+        $keyring = null;
+        if (!$publicKey && !$privateKey) {
+            $keyring = \Keyring::generate($identifier, 'global', $privateKeyPassPhrase, $algorithm);
+        } else {
+            $keyring = \Keyring::findOneBySQL('range_id = :id', ['id' => $identifier]);
+            if ($keyring) {
+                return $keyring->toKeyChain();
+            } else {
+                throw new \Studip\KeyringException(
+                    'Keyring not found.',
+                    \Studip\KeyringException::NOT_FOUND
+                );
+
+            }
+        }
+        return $keyring;
+    }
+}
diff --git a/lib/classes/LTI13a/KeyManager.php b/lib/classes/LTI13a/KeyManager.php
new file mode 100644
index 00000000000..13a60015c76
--- /dev/null
+++ b/lib/classes/LTI13a/KeyManager.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Studip\LTI13a;
+
+use OAT\Library\Lti1p3Core\Security\Key\KeyChainInterface;
+use OAT\Library\Lti1p3Core\Security\Key\KeyChainRepositoryInterface;
+
+class KeyManager implements KeyChainRepositoryInterface
+{
+
+    #[\Override]
+    public function find(string $identifier): ?KeyChainInterface
+    {
+        $keyring = \Keyring::findOneByRange_id($identifier);
+        if ($keyring) {
+            return $keyring->toKeyChain();
+        }
+        return null;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    #[\Override]
+    public function findByKeySetName(string $keySetName): array
+    {
+        $keyring = \Keyring::findOneByRange_id($keySetName);
+        if ($keyring) {
+            return [$keyring->toKeyChain()];
+        }
+        return [];
+    }
+}
diff --git a/lib/classes/LTI13a/LineItemRepository.php b/lib/classes/LTI13a/LineItemRepository.php
new file mode 100644
index 00000000000..add863efcc7
--- /dev/null
+++ b/lib/classes/LTI13a/LineItemRepository.php
@@ -0,0 +1,192 @@
+<?php
+
+namespace Studip\LTI13a;
+
+use OAT\Library\Lti1p3Ags\Model\LineItem\LineItemCollection;
+use OAT\Library\Lti1p3Ags\Model\LineItem\LineItemCollectionInterface;
+use OAT\Library\Lti1p3Ags\Model\LineItem\LineItemInterface;
+use OAT\Library\Lti1p3Ags\Repository\LineItemRepositoryInterface;
+use Studip\LTIException;
+use Grading\Definition;
+
+class LineItemRepository implements LineItemRepositoryInterface
+{
+    /**
+     * Converts the tool-ID and deployment-ID in the tool name used in the
+     * Stud.IP grading context.
+     *
+     * @param string $tool_id The Stud.IP LTI tool ID.
+     * @param string $deployment_id The Stud.IP LTI deployment ID.
+     * @return string The corresponding tool name used in the Stud.IP grading context.
+     */
+    public static function getGradingToolName(string $tool_id, string $deployment_id) : string
+    {
+        return sprintf('lti-%s-%s', $tool_id, $deployment_id);
+    }
+
+    /**
+     * Converts the LTI line item identifier to search parameters to retrieve
+     * Stud.IP grading definitions.
+     *
+     * @param string $line_item_identifier The LTI line item identifier.
+     *
+     * @return array The search parameters for searching in the Stud.IP grading context.
+     *     This is an associative array with two keys:
+     *         'tool'       => The identifier of the tool in the Stud.IP grading context.
+     *         'course_id'  => The Stud.IP course-ID.
+     *     In case the search parameters cannot be generated, an empty array is returned.
+     */
+    public static function getSearchParametersFromLineItemIdentifier(string $line_item_identifier) : array
+    {
+        //$lineItemIdentifier contains the full URL to the line item.
+        //We must extract the course-ID, tool-ID and deployment-ID
+        //from the URL parameters first, before searching a grading definition.
+        $url_parts = parse_url($line_item_identifier);
+        $parameters = [];
+        if (empty($url_parts['query'])) {
+            //Nothing we can convert.
+            return [];
+        }
+        parse_str($url_parts['query'], $parameters);
+        if (empty($parameters)) {
+            //Same as above.
+            return [];
+        }
+
+        $search_parameters = [
+            'course_id' => $parameters['cid'],
+            'tool'      => self::getGradingToolName($parameters['tool_id'], $parameters['deployment_id'])
+        ];
+        if (!empty($parameters['definition_id'])) {
+            $search_parameters['definition_id'] = $parameters['definition_id'];
+        }
+
+        return $search_parameters;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function find(string $lineItemIdentifier): ?LineItemInterface
+    {
+        $search_parameters = self::getSearchParametersFromLineItemIdentifier($lineItemIdentifier);
+        if (!$search_parameters) {
+            //Nothing we can search for.
+            return null;
+        }
+
+        $definition = null;
+        if (!empty($search_parameters['definition_id'])) {
+            $definition = Definition::find($search_parameters['definition_id']);
+        } else {
+            $definition = Definition::findOneBySQL(
+                "`course_id` = :course_id AND `tool` = :tool",
+                [
+                    'course_id' => $search_parameters['course_id'],
+                    'tool' => $search_parameters['tool']
+                ]
+            );
+        }
+        if ($definition) {
+            return $definition->toLineItem();
+        }
+        return null;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function findCollection(
+        ?string $resourceIdentifier = null,
+        ?string $resourceLinkIdentifier = null,
+        ?string $tag = null,
+        ?int $limit = null,
+        ?int $offset = null
+    ): LineItemCollectionInterface
+    {
+        $result = new LineItemCollection();
+        if (!$resourceLinkIdentifier) {
+            //Nothing we can search for.
+            return $result;
+        }
+
+        //$resourceLinkIdentifier contains the Stud.IP tool-ID, the deployment-ID and the course-ID,
+        //separated by underscores.
+        $id_parts = explode('_', $resourceLinkIdentifier);
+        if (count($id_parts) !== 3) {
+            throw new LTIException('Invalid resource link identifier.');
+        }
+        $tool_id       = $id_parts[0];
+        $deployment_id = $id_parts[1];
+        $course_id     = $id_parts[2];
+
+        $sql = '';
+        $sql_params = [];
+        if ($tool_id && $course_id) {
+            $sql .= "`tool` = :tool AND `course_id` = :course_id";
+            $sql_params['tool']      = self::getGradingToolName($tool_id, $deployment_id);
+            $sql_params['course_id'] = $course_id;
+        } else {
+            //No tool-ID means no line item collection can be found.
+            return $result;
+        }
+
+        if ($limit) {
+            if (empty($sql)) {
+                $sql .= "TRUE ";
+            }
+            $sql .= "LIMIT :limit ";
+            $sql_params['limit'] = $limit;
+        }
+        if ($offset) {
+            $sql .= "OFFSET :offset";
+            $sql_params['offset'] = $offset;
+        }
+        $definitions = Definition::findBySql($sql, $sql_params);
+
+        foreach ($definitions as $definition) {
+            $result->add($definition->toLineItem());
+        }
+        return $result;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function save(LineItemInterface $lineItem): LineItemInterface
+    {
+        //The resource link identifier contains the Stud.IP tool-ID, deployment-ID and course-ID
+        //separated by underscores.
+        $studip_ids    = explode('_', $lineItem->getResourceLinkIdentifier() ?? '');
+        $tool_id       = $studip_ids[0];
+        $deployment_id = $studip_ids[1];
+        $course_id     = $studip_ids[2];
+
+        $definition            = new Definition();
+        $definition->id        = $lineItem->getIdentifier();
+        $definition->name      = $lineItem->getLabel();
+        $definition->course_id = $course_id;
+        $definition->tool      = sprintf('lti-%s-%s', $tool_id, $deployment_id);
+        $definition->weight    = '1.0';
+        if ($definition->store()) {
+            return $definition->toLineItem();
+        } else {
+            throw new LTIException('Could not save line item.');
+        }
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function delete(string $lineItemIdentifier): void
+    {
+        $search_parameters = self::getSearchParametersFromLineItemIdentifier($lineItemIdentifier);
+        $definition = Definition::findOneBySQL(
+            '`course_id` = :course_id AND `tool` = :tool',
+            $search_parameters
+        );
+        if ($definition) {
+            $definition->delete();
+        }
+    }
+}
diff --git a/lib/classes/LTI13a/NonceGenerator.php b/lib/classes/LTI13a/NonceGenerator.php
new file mode 100644
index 00000000000..8550709dabb
--- /dev/null
+++ b/lib/classes/LTI13a/NonceGenerator.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Studip\LTI13a;
+
+use OAT\Library\Lti1p3Core\Security\Nonce\NonceGeneratorInterface;
+use OAT\Library\Lti1p3Core\Security\Nonce\NonceInterface;
+use OAT\Library\Lti1p3Core\Security\Nonce\Nonce;
+
+class NonceGenerator implements NonceGeneratorInterface
+{
+    public function __construct(
+        protected bool $pass_nonce_from_request = false
+    ) {
+    }
+
+    #[\Override]
+    public function generate(?int $ttl = null): NonceInterface
+    {
+        $expiration = new \DateTime();
+        $expiration = $expiration->add(new \DateInterval('PT5M'));
+        if ($this->pass_nonce_from_request) {
+            return new Nonce(
+                \Request::get('nonce'),
+                $expiration
+            );
+        } else {
+            $nonce = md5(random_bytes(16) . 'lti13a_nonce');
+            return new Nonce(
+                $nonce,
+                $expiration
+            );
+        }
+    }
+}
diff --git a/lib/classes/LTI13a/PlatformManager.php b/lib/classes/LTI13a/PlatformManager.php
new file mode 100644
index 00000000000..10c7cb19696
--- /dev/null
+++ b/lib/classes/LTI13a/PlatformManager.php
@@ -0,0 +1,106 @@
+<?php
+
+namespace Studip\LTI13a;
+
+use OAT\Library\Lti1p3Core\Platform\Platform;
+use OAT\Library\Lti1p3DeepLinking\Settings\DeepLinkingSettings;
+use OAT\Library\Lti1p3Core\Resource\LtiResourceLink\LtiResourceLinkInterface;
+
+class PlatformManager
+{
+    /**
+     * Generates an object containing the configuration to use this Stud.IP
+     * as LTI 1.3A platform.
+     *
+     * @return Platform The platform configuration.
+     */
+    public static function getPlatformConfiguration() : Platform
+    {
+        $c = \Config::get();
+
+        return new Platform(
+            $c->STUDIP_INSTALLATION_ID,
+            $c->UNI_NAME_CLEAN,
+            $GLOBALS['ABSOLUTE_URI_STUDIP'],
+            \URLHelper::getURL('dispatch.php/lti/auth/oidc_init', null, true),
+            \URLHelper::getURL('dispatch.php/lti/auth/oauth2_token', null, true)
+        );
+    }
+
+    /**
+     * Generates an object containing the settings for using this Stud.IP
+     * as a platform that connects to an LTI tool via Deep Linking.
+     *
+     * @param string $tool_id An optional LTI tool ID that is used to construct
+     *     the platform return URL.
+     *
+     * @return DeepLinkingSettings The settings for deep linking.
+     */
+    public static function getDeepLinkingConfiguration(string $tool_id = '') : DeepLinkingSettings
+    {
+        $c = \Config::get();
+
+        return new DeepLinkingSettings(
+            self::getDeepLinkingReturnUrl($tool_id),
+            [LtiResourceLinkInterface::TYPE],
+            ['window', 'iframe'],
+            'text/html',
+            true,
+            false,
+            $c->UNI_NAME_CLEAN,
+            ''
+        );
+    }
+
+    /**
+     * Returns the keyring for the platform.
+     *
+     * @return \Keyring|null The keyring for the platform or null if no such keyring exists.
+     */
+    public static function getPlatformKeyring() : ?\Keyring
+    {
+        return \Keyring::findOneBySQL("`range_type` = 'global' AND `range_id` = 'lti13a_platform'");
+    }
+
+    public static function generatePlatformKeyring() : \Keyring
+    {
+        return \Keyring::generate('lti13a_platform', 'global');
+    }
+
+    public static function getLtiRoleClaimForStudipRole(string $role) : string
+    {
+        if (in_array($role, ['dozent', 'admin', 'root'])) {
+            //Lecturer/admin
+            return 'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor';
+        } elseif ($role === 'tutor') {
+            return 'http://purl.imsglobal.org/vocab/lis/v2/membership#Mentor';
+        } elseif (in_array($role, ['user', 'autor'])) {
+            //Learner
+            return  'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner';
+        }
+        //Invalid role:
+        return '';
+    }
+
+    /**
+     * Generates the URL for returning from the tool in an LTI deep linking process.
+     *
+     * @param string $tool_id The optional LTI Tool-ID to append to the URL.
+     *
+     * @return string The URL for returning from an LTI deep linking process.
+     */
+    public static function getDeepLinkingReturnUrl(string $tool_id = '') : string
+    {
+        return \URLHelper::getURL('dispatch.php/course/lti/save_link/' . $tool_id, null, true);
+    }
+
+    /**
+     * Returns the URL from which the JSON web key set (JWKS) can be retrieved.
+     *
+     * @return string The JWKS URL.
+     */
+    public static function getJwksUrl() : string
+    {
+        return \URLHelper::getURL('dispatch.php/lti/auth/jwks');
+    }
+}
diff --git a/lib/classes/LTI13a/Registration.php b/lib/classes/LTI13a/Registration.php
new file mode 100644
index 00000000000..c4b898f7b5b
--- /dev/null
+++ b/lib/classes/LTI13a/Registration.php
@@ -0,0 +1,122 @@
+<?php
+
+namespace Studip\LTI13a;
+
+use OAT\Library\Lti1p3Core\Registration\RegistrationInterface;
+use OAT\Library\Lti1p3Core\Tool\ToolInterface;
+use OAT\Library\Lti1p3Core\Platform\PlatformInterface;
+use OAT\Library\Lti1p3Core\Security\Key\KeyChainInterface;
+
+class Registration implements RegistrationInterface
+{
+    public function __construct(
+        protected ?\LtiTool $tool
+    ) {
+    }
+
+    public function setLtiTool(\LtiTool $tool)
+    {
+        $this->tool = $tool;
+    }
+
+    public function getLtiTool() : ?\LtiTool
+    {
+        return $this->tool;
+    }
+
+    #[\Override]
+    public function getIdentifier(): string
+    {
+        if (!$this->tool) {
+            return '';
+        }
+        return $this->tool->id;
+    }
+
+    #[\Override]
+    public function getClientId(): string
+    {
+        return $this->tool->id ?? '';
+    }
+
+    #[\Override]
+    public function getPlatform(): PlatformInterface
+    {
+        return PlatformManager::getPlatformConfiguration();
+    }
+
+    #[\Override]
+    public function getTool(): ToolInterface
+    {
+        if (!$this->tool) {
+            throw new \Studip\LTIException(
+                'No LTI tool link present.',
+                \Studip\LTIException::REGISTRATION_NOT_LINKED_TO_TOOL
+            );
+        }
+        return $this->tool->getToolData();
+    }
+
+    #[\Override]
+    public function getDeploymentIds(): array
+    {
+        if (!$this->tool) {
+            return [];
+        }
+        return \DBManager::get()->fetchFirst("SELECT `id` FROM `lti_deployments` WHERE `tool_id` = ?", [$this->tool->id]);
+    }
+
+    #[\Override]
+    public function hasDeploymentId(string $deploymentId): bool
+    {
+        if (!$this->tool) {
+            return false;
+        }
+        return \LtiDeployment::countBySql(
+            "`tool_id` = :tool_id AND `id` = :deployment_id",
+            ['tool_id' => $this->tool->id, 'deployment_id' => $deploymentId]
+        ) > 0;
+    }
+
+    #[\Override]
+    public function getDefaultDeploymentId(): ?string
+    {
+        //There is no default deployment-ID in Stud.IP:
+        return null;
+    }
+
+    #[\Override]
+    public function getPlatformKeyChain(): ?KeyChainInterface
+    {
+        $platform_keyring = PlatformManager::getPlatformKeyring();
+        if (!$platform_keyring) {
+            $platform_keyring = PlatformManager::generatePlatformKeyring();
+        }
+        return $platform_keyring->toKeyChain();
+    }
+
+    #[\Override]
+    public function getToolKeyChain(): ?KeyChainInterface
+    {
+        if (!$this->tool) {
+            return null;
+        }
+        $keyring = $this->tool->getKeyring();
+        if (!$keyring) {
+            $keyring = $this->tool->getKeyring(true);
+        }
+        return $keyring->toKeyChain();
+    }
+
+    #[\Override]
+    public function getPlatformJwksUrl(): ?string
+    {
+        return PlatformManager::getJwksUrl();
+    }
+
+    #[\Override]
+    public function getToolJwksUrl(): ?string
+    {
+        return $this->tool->jwks_url ?? null;
+    }
+}
diff --git a/lib/classes/LTI13a/RegistrationManager.php b/lib/classes/LTI13a/RegistrationManager.php
new file mode 100644
index 00000000000..922cb5876a7
--- /dev/null
+++ b/lib/classes/LTI13a/RegistrationManager.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace Studip\LTI13a;
+
+use OAT\Library\Lti1p3Core\Registration\RegistrationRepositoryInterface;
+use OAT\Library\Lti1p3Core\Registration\RegistrationInterface;
+
+class RegistrationManager implements RegistrationRepositoryInterface
+{
+    #[\Override]
+    public function find(string $identifier): ?RegistrationInterface
+    {
+        //The identifier is the ID of a tool.
+        $tool = \LtiTool::find($identifier);
+        if (!$tool) {
+            return null;
+        }
+        return new Registration($tool);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    #[\Override]
+    public function findAll(): array
+    {
+        $tools = \LtiTool::findBySQL('TRUE');
+        $registrations = [];
+        foreach ($tools as $tool) {
+            $registrations[] = new Registration($tool);
+        }
+        return $registrations;
+    }
+
+    #[\Override]
+    public function findByClientId(string $clientId): ?RegistrationInterface
+    {
+        //Find a registration by its client-ID. The client-ID is equivalent to the tool-ID in Stud.IP.
+        if (!$clientId) {
+            //Nothing to search for.
+            return null;
+        }
+        $tool = \LtiTool::find($clientId);
+        if ($tool) {
+            return new Registration($tool);
+        }
+        return null;
+    }
+
+    #[\Override]
+    public function findByPlatformIssuer(string $issuer, string $clientId = null): ?RegistrationInterface
+    {
+        //Only handle requests for registrations of this Stud.IP:
+        if ($issuer !== \Config::get()->STUDIP_INSTALLATION_ID) {
+            //Invalid issuer.
+            return null;
+        }
+        return $this->findByClientId($clientId);
+    }
+
+    #[\Override]
+    public function findByToolIssuer(string $issuer, string $clientId = null): ?RegistrationInterface
+    {
+        //Tool registrations are not supported at this moment.
+        return null;
+    }
+}
diff --git a/lib/classes/LTI13a/ResultRepository.php b/lib/classes/LTI13a/ResultRepository.php
new file mode 100644
index 00000000000..350bf39c39d
--- /dev/null
+++ b/lib/classes/LTI13a/ResultRepository.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Studip\LTI13a;
+
+use OAT\Library\Lti1p3Ags\Model\Result\Result;
+use OAT\Library\Lti1p3Ags\Model\Result\ResultCollection;
+use OAT\Library\Lti1p3Ags\Model\Result\ResultCollectionInterface;
+use OAT\Library\Lti1p3Ags\Model\Result\ResultInterface;
+use OAT\Library\Lti1p3Ags\Repository\ResultRepositoryInterface;
+
+class ResultRepository implements ResultRepositoryInterface
+{
+    public function findCollectionByLineItemIdentifier(
+        string $lineItemIdentifier,
+        ?int $limit = null,
+        ?int $offset = null
+    ) : ResultCollectionInterface {
+        $sql_params = LineItemRepository::getSearchParametersFromLineItemIdentifier($lineItemIdentifier);
+        if (!$sql_params) {
+            //Nothing we can search for:
+            return new ResultCollection();
+        }
+        $sql = 'JOIN `grading_definitions` gd
+               ON (`definition_id` = gd.`id`)
+               WHERE gd.`course_id` = :course_id
+               AND gd.`tool` = :tool';
+        if ($limit) {
+            $sql .= 'LIMIT :limit ';
+            $sql_params['limit'] = $limit;
+        }
+        if ($offset) {
+            $sql .= 'OFFSET :offset ';
+            $sql_params['offset'] = $offset;
+        }
+
+        $grades = \Grading\Instance::findBySQL($sql, $sql_params);
+        $results = new ResultCollection();
+        foreach ($grades as $grade) {
+            $results->add($grade->toResult());
+        }
+        return $results;
+    }
+
+    public function findByLineItemIdentifierAndUserIdentifier(
+        string $lineItemIdentifier,
+        string $userIdentifier
+    ) : ?ResultInterface {
+        $search_parameters = LineItemRepository::getSearchParametersFromLineItemIdentifier($lineItemIdentifier);
+        $search_parameters['user_id'] = $userIdentifier;
+
+        $grade = \Grading\Instance::findOneBySQL(
+            'JOIN `grading_definitions` gd
+               ON (`definition_id` = gd.`id`)
+               WHERE gd.`course_id` = :course_id
+               AND gd.`tool` = :tool
+               AND `user_id` = :user_id',
+            $search_parameters
+        );
+        if ($grade) {
+            return $grade->toResult();
+        }
+        return null;
+    }
+}
diff --git a/lib/classes/LTI13a/ScoreRepository.php b/lib/classes/LTI13a/ScoreRepository.php
new file mode 100644
index 00000000000..06cf452f6eb
--- /dev/null
+++ b/lib/classes/LTI13a/ScoreRepository.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Studip\LTI13a;
+
+use OAT\Library\Lti1p3Ags\Model\Score\ScoreInterface;
+use OAT\Library\Lti1p3Ags\Repository\ScoreRepositoryInterface;
+
+class ScoreRepository implements ScoreRepositoryInterface
+{
+    public function save(ScoreInterface $score): ScoreInterface
+    {
+        $user_id = $score->getUserIdentifier();
+        $definition_id = $score->getLineItemIdentifier();
+
+        $grade = \Grading\Instance::findOneBySQL(
+            '`definition_id` = :definition_id AND `user_id` = :user_id',
+            ['definition_id' => $definition_id, 'user_id' => $user_id]
+        );
+        if (!$grade) {
+            $grade = new \Grading\Instance();
+            $grade->definition_id = $definition_id;
+            $grade->user_id       = $user_id;
+        }
+        $grade->rawgrade = $score->getScoreGiven();
+        $grade->feedback = $score->getComment();
+        $grade->store();
+        return $score;
+    }
+}
diff --git a/lib/classes/LTI13a/UserAuthenticator.php b/lib/classes/LTI13a/UserAuthenticator.php
new file mode 100644
index 00000000000..125a6085686
--- /dev/null
+++ b/lib/classes/LTI13a/UserAuthenticator.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Studip\LTI13a;
+
+use OAT\Library\Lti1p3Core\Security\User\UserAuthenticatorInterface;
+use OAT\Library\Lti1p3Core\Registration\RegistrationInterface;
+use OAT\Library\Lti1p3Core\Security\User\Result\UserAuthenticationResultInterface;
+use OAT\Library\Lti1p3Core\Security\User\Result\UserAuthenticationResult;
+use Psr\Log\LoggerInterface;
+
+class UserAuthenticator implements UserAuthenticatorInterface
+{
+    protected $logger = null;
+
+    public function setLogger(LoggerInterface $logger)
+    {
+        $this->logger = $logger;
+    }
+
+    #[\Override]
+    public function authenticate(RegistrationInterface $registration, string $loginHint): UserAuthenticationResultInterface
+    {
+        $user = \User::find($loginHint);
+
+        $identity = null;
+        $tool = null;
+        if ($registration instanceof Registration) {
+            $tool = $registration->getLtiTool();
+        } else {
+            $tool = \LtiTool::find($registration->getIdentifier());
+        }
+        if ($user && $tool) {
+            $identity = new Identity($user, $tool);
+        }
+
+        return new UserAuthenticationResult($user instanceof \User, $identity);
+    }
+}
diff --git a/lib/classes/auth_plugins/StudipAuthLTI.php b/lib/classes/auth_plugins/StudipAuthLTI.php
index d5a286334f2..7fd58016420 100644
--- a/lib/classes/auth_plugins/StudipAuthLTI.php
+++ b/lib/classes/auth_plugins/StudipAuthLTI.php
@@ -1,7 +1,8 @@
 <?php
 /*
- * StudipAuthLTI.php - Stud.IP authentication against LTI 1.1 consumer
+ * StudipAuthLTI.class.php - Stud.IP authentication against an LTI 1.3A consumer
  * Copyright (c) 2018  Elmar Ludwig
+ * Copyright (c) 2023-2024  Moritz Strohm
  *
  * This program is free software; you can redistribute it and/or
  * modify it under the terms of the GNU General Public License as
@@ -11,7 +12,13 @@
 
 use Studip\OAuth2\NegotiatesWithPsr7;
 
-class StudipAuthLTI extends StudipAuthSSO
+use OAT\Library\Lti1p3Core\Registration\RegistrationInterface;
+use OAT\Library\Lti1p3Core\Security\User\Result\UserAuthenticationResultInterface;
+use OAT\Library\Lti1p3Core\Security\User\UserAuthenticatorInterface;
+use OAT\Library\Lti1p3Core\Security\User\Result\UserAuthenticationResult;
+use OAT\Library\Lti1p3Core\User\UserIdentity;
+
+class StudipAuthLTI extends StudipAuthSSO implements UserAuthenticatorInterface
 {
     use NegotiatesWithPsr7;
 
@@ -132,4 +139,35 @@ class StudipAuthLTI extends StudipAuthSSO
     {
         return Request::get($key);
     }
+
+    //\OAT\Library\Lti1p3Core\Security\User\UserAuthenticatorInterface implementation:
+
+    public function authenticate(RegistrationInterface $registration, string $loginHint) : UserAuthenticationResultInterface
+    {
+        //Check if the user-ID is known:
+        $user = User::find($loginHint);
+        if (!$user) {
+            return new UserAuthenticationResult(false, null);
+        }
+
+        //Authenticate the user:
+        if ($this->authenticateUser($user->username, '')) {
+            return new UserAuthenticationResult(
+                true,
+                new UserIdentity(
+                    $user->id,
+                    $user->getFullName(),
+                    $user->email,
+                    $user->vorname,
+                    $user->nachname,
+                    '',
+                    $user->preferred_language,
+                    Avatar::getAvatar($user->id)->getURL(Avatar::SMALL)
+                )
+            );
+        }
+
+        //The user could not be authenticated:
+        return new UserAuthenticationResult(false, null);
+    }
 }
diff --git a/lib/exceptions/KeyringException.php b/lib/exceptions/KeyringException.php
new file mode 100644
index 00000000000..df797192aa1
--- /dev/null
+++ b/lib/exceptions/KeyringException.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Studip;
+
+use Studip\Exception;
+
+/**
+ * The KeyringException class represents exceptions that occurr when using keyrings.
+ */
+class KeyringException extends Exception
+{
+    /**
+     * The CREATION_FAILED status code means that a keyring could not be created.
+     */
+    public const CREATION_FAILED = 1;
+
+    /**
+     * The NOT FOUND status code means that the search for a keyring did not yield a result.
+     */
+    public const NOT_FOUND = 2;
+
+    /**
+     * The UNSUPPORTED_KEY_ALGORITHM status code means that the selected key algorithm
+     * is not supported.
+     */
+    public const UNSUPPORTED_KEY_ALGORITHM = 3;
+}
diff --git a/lib/exceptions/LTIException.php b/lib/exceptions/LTIException.php
new file mode 100644
index 00000000000..9e096da0983
--- /dev/null
+++ b/lib/exceptions/LTIException.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Studip;
+
+use Studip\Exception;
+
+/**
+ * The LTIException class represents exceptions that occur in the Stud.IP LTI interface.
+ */
+class LTIException extends Exception
+{
+    /**
+     * The REGISTRATION_NOT_LINKED_TO_TOOL status code represents the case where
+     * a LTI tool registration is not linked to a tool.
+     */
+    public const REGISTRATION_NOT_LINKED_TO_TOOL = 1;
+}
diff --git a/lib/models/Grading/Definition.php b/lib/models/Grading/Definition.php
index 41b4f7171fb..26c2bba0c8c 100644
--- a/lib/models/Grading/Definition.php
+++ b/lib/models/Grading/Definition.php
@@ -2,6 +2,10 @@
 
 namespace Grading;
 
+use OAT\Library\Lti1p3Ags\Model\LineItem\LineItem;
+use OAT\Library\Lti1p3Ags\Model\LineItem\LineItemInterface;
+use OAT\Library\Lti1p3Ags\Model\LineItem\LineItemSubmissionReview;
+
 /**
  * @license GPL2 or any later version
  *
@@ -65,4 +69,31 @@ class Definition extends \SimpleORMap
     {
         return Definition::findBySQL('course_id = ? ORDER BY position ASC, name ASC', [$course->id]);
     }
+
+    public function toLineItem() : LineItemInterface
+    {
+        //Build the resource link identifier first:
+        $studip_ids = explode('-', $this->tool ?? '');
+        $tool_id       = $studip_ids[1] ?? '';
+        $deployment_id = $studip_ids[2] ?? '';
+        $resource_link_identifier = sprintf('%s_%s_%s', $tool_id, $deployment_id, $this->course_id);
+
+        $identifier = \URLHelper::getURL(
+            'dispatch.php/lti/ags/line_item',
+            [
+                'cid'           => $this->course_id,
+                'definition_id' => $this->id,
+                'deployment_id' => $deployment_id,
+                'tool_id'       => $tool_id
+            ]
+        );
+
+        return new LineItem(
+            PHP_FLOAT_MAX,
+            $this->name,
+            $identifier,
+            $deployment_id,
+            $resource_link_identifier
+        );
+    }
 }
diff --git a/lib/models/Grading/Instance.php b/lib/models/Grading/Instance.php
index 7f362d346e3..8d9280129b6 100644
--- a/lib/models/Grading/Instance.php
+++ b/lib/models/Grading/Instance.php
@@ -2,6 +2,8 @@
 
 namespace Grading;
 
+use OAT\Library\Lti1p3Ags\Model\Result\Result;
+
 /**
  * @license GPL2 or any later version
  *
@@ -84,4 +86,16 @@ class Instance extends \SimpleORMap
         }
         return $this->content['rawgrade'] = number_format($grade, 5, '.', '');
     }
+
+    public function toResult() : Result
+    {
+        return new Result(
+            $this->user_id,
+            $this->definition_id,
+            $this->user_id . '_' . $this->definition_id,
+            $this->rawgrade,
+            9.99999, //see above
+            $this->feedback
+        );
+    }
 }
diff --git a/lib/models/Keyring.php b/lib/models/Keyring.php
new file mode 100644
index 00000000000..b4cfffc05b7
--- /dev/null
+++ b/lib/models/Keyring.php
@@ -0,0 +1,169 @@
+<?php
+/**
+ * Keyring.php
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ *
+ * @author      Moritz Strohm <strohm@data-quest.de>
+ * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
+ * @since       6.0
+ */
+
+/**
+ * The Keyring class stores cryptographic keyrings in the database.
+ *
+ * @property string id database column
+ * @property string range_id database column
+ * @property string range_type database column
+ * @property string public_key database column
+ * @property string private_key database column
+ * @property string passphrase database column
+ * @property string mkdate database column
+ * @property string chdate database column
+ */
+class Keyring extends SimpleORMap
+{
+    private const ALGORTIHM_RS256 = 'RSA-OAEP-256';
+    protected static function configure($config = [])
+    {
+        $config['db_table'] = 'keyrings';
+        parent::configure($config);
+    }
+
+    /**
+     * This method generates a new keyring.
+     *
+     * @param string $range_id The ID of the range for which to generate the keyring.
+     *
+     * @param string $range_type The type of range for which to generate the keyring.
+     *
+     * @param string $passphrase An optional passphrase for the keyring. This should not be stored
+     *     as plain text in here, but instead in a cryptographically hashed form.
+     *
+     * @param string $algorithm The algorithm to use for the new keyring.
+     *
+     * @return Keyring The generated keyring.
+     *
+     * @throws \Studip\KeyringException In case no keyring can be generated, a KeyringException is thrown.
+     */
+    public static function generate(
+        string $range_id,
+        string $range_type,
+        string $passphrase = '',
+        string $algorithm = self::ALGORTIHM_RS256
+    ) : Keyring
+    {
+        if ($algorithm === self::ALGORTIHM_RS256) {
+            $private_key = phpseclib3\Crypt\RSA::createKey(4096);
+            if ($passphrase) {
+                $private_key = $private_key->withPassword($passphrase);
+            }
+            //Explicitly set OAEP as padding method:
+            $private_key->withPadding(\phpseclib\Crypt\RSA::ENCRYPTION_OAEP);
+            //Explicitly set sha256:
+            $private_key->withHash('sha256');
+
+            $public_key = $private_key->getPublicKey();
+            $keyring = new self();
+            $keyring->range_id = $range_id;
+            $keyring->range_type = $range_type;
+            $keyring->private_key = $private_key;
+            $keyring->public_key = $public_key;
+            if ($passphrase) {
+                $hasher = UserManagement::getPwdHasher();
+                $keyring->passphrase = $hasher->HashPassword($passphrase);
+            }
+            if ($keyring->store()) {
+                return $keyring;
+            }
+            throw new \Studip\KeyringException(
+                _('Es konnte kein Schlüsselpaar erzeugt werden.'),
+                \Studip\KeyringException::CREATION_FAILED
+            );
+        } else {
+            throw new \Studip\KeyringException(
+                sprintf(
+                    _('Der Schlüsselalgorithmus %s wird nicht unterstützt.'),
+                    $algorithm
+                ),
+                \Studip\KeyringException::UNSUPPORTED_KEY_ALGORITHM
+            );
+        }
+    }
+
+    /**
+     * Generates a new keyring from a public key.
+     * This method will not attempt to re-create the private key from the public key.
+     * Instead, the public key is the only part of the key that is stored in a new
+     * keyring instance.
+     *
+     * @param \OAT\Library\Lti1p3Core\Security\Key\KeyInterface|string $key The public key.
+     *
+     * @param string $range_type The range type for the keyring.
+     *
+     * @param string $range_id The range-ID for the keyring.
+     *
+     * @return Keyring|null A keyring, if it can be created or null in case of failure to do so.
+     */
+    public static function createFromPublicKey(
+        \OAT\Library\Lti1p3Core\Security\Key\KeyInterface|string $key,
+        string $range_type,
+        string $range_id
+    ) : ?Keyring
+    {
+        $keyring = new self();
+        $keyring->range_type = $range_type;
+        $keyring->range_id = $range_id;
+        $keyring->private_key = '';
+
+        if (is_string($key)) {
+            $keyring->public_key = $key;
+        } else {
+            //Instance of KeyInterface:
+            $content = $key->getContent();
+            if (!$content || empty($content['n']) || empty($content['e'])) {
+                //No key present or base or exponent missing.
+                return null;
+            }
+
+            $loaded_key = \phpseclib3\Crypt\RSA::loadPublicKey([
+                'e' => new \phpseclib3\Math\BigInteger(base64_decode(strtr($content['e'], '-_', '+/'))),
+                'n' => new \phpseclib3\Math\BigInteger(base64_decode(strtr($content['n'], '-_', '+/'))),
+            ]);
+            $keyring->public_key = $loaded_key->toString('PKCS8');
+        }
+        return $keyring;
+    }
+
+    /**
+     * Converts the keyring to a KeyChain instance of the Lti1p3Core library.
+     *
+     * @return \OAT\Library\Lti1p3Core\Security\Key\KeyChain A KeyChain representation
+     *     of the keyring.
+     */
+    public function toKeyChain() : \OAT\Library\Lti1p3Core\Security\Key\KeyChain
+    {
+        $public_key = new \OAT\Library\Lti1p3Core\Security\Key\Key(
+            $this->public_key
+        );
+
+        //A private key is optional.
+        $private_key = null;
+        if ($this->private_key) {
+            $private_key = new \OAT\Library\Lti1p3Core\Security\Key\Key(
+                $this->private_key,
+                $this->passphrase ?? null
+            );
+        }
+
+        return new \OAT\Library\Lti1p3Core\Security\Key\KeyChain(
+            $this->id,
+            $this->range_id,
+            $public_key,
+            $private_key
+        );
+    }
+}
diff --git a/lib/models/LtiData.php b/lib/models/LtiDeployment.php
similarity index 55%
rename from lib/models/LtiData.php
rename to lib/models/LtiDeployment.php
index 0c73dbcbaf3..98602796810 100644
--- a/lib/models/LtiData.php
+++ b/lib/models/LtiDeployment.php
@@ -1,6 +1,6 @@
 <?php
 /**
- * LtiData.php - LTI consumer API for Stud.IP
+ * LtiDeployment.php - A class that represents an LTI tool deployment.
  *
  * This program is free software; you can redistribute it and/or
  * modify it under the terms of the GNU General Public License as
@@ -8,6 +8,7 @@
  * the License, or (at your option) any later version.
  *
  * @author      Elmar Ludwig
+ * @author      Moritz Strohm
  * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
  *
  * @property int $id database column
@@ -25,14 +26,14 @@
  * @property LtiTool $tool belongs_to LtiTool
  */
 
-class LtiData extends SimpleORMap
+class LtiDeployment extends SimpleORMap
 {
     /**
      * Configure the database mapping.
      */
     protected static function configure($config = [])
     {
-        $config['db_table'] = 'lti_data';
+        $config['db_table'] = 'lti_deployments';
 
         $config['serialized_fields']['options'] = JSONArrayObject::class;
 
@@ -51,9 +52,23 @@ class LtiData extends SimpleORMap
             'on_delete'         => 'delete'
         ];
 
+        $config['registered_callbacks']['before_create'] = ['cbCalculatePosition'];
+
         parent::configure($config);
     }
 
+    /**
+     * Calculates the position of the new deployment in the course.
+     */
+    public function cbCalculatePosition() : void
+    {
+        $this->position = self::countBySql(
+            'JOIN `lti_tools` ON `tool_id` = `lti_tools`.`id`
+            WHERE `lti_tools`.`range_id` = :range_id',
+            ['range_id' => $this->tool->range_id]
+            ) + 1;
+    }
+
     /**
      * Find a single entry by course_id and position.
      *
@@ -74,23 +89,26 @@ class LtiData extends SimpleORMap
         $position = $this->position;
 
         if ($result = parent::delete()) {
-            $db->execute('UPDATE lti_data SET position = position - 1 WHERE course_id = ? AND position > ?', [$course_id, $position]);
+            $db->execute('UPDATE `lti_deployments` SET `position` = position - 1 WHERE `course_id` = ? AND `position` > ?', [$course_id, $position]);
         }
 
         return $result;
     }
 
+    public function getToolLtiVersion() : string
+    {
+        return $this->tool->lti_version ?? '';
+    }
+
+
     /**
      * Get the launch_url of this entry.
      */
     public function getLaunchURL()
     {
-        if ($this->tool_id) {
-            if (!$this->tool->allow_custom_url && !$this->tool->deep_linking || !$this->launch_url) {
-                return $this->tool->launch_url;
-            }
+        if (empty($this->tool->allow_custom_url) && empty($this->tool->deep_linking) || empty($this->launch_url)) {
+            return $this->tool->launch_url ?? '';
         }
-
         return $this->launch_url;
     }
 
@@ -99,11 +117,7 @@ class LtiData extends SimpleORMap
      */
     public function getConsumerKey()
     {
-        if ($this->tool_id) {
-            return $this->tool->consumer_key;
-        }
-
-        return $this->options['consumer_key'];
+        return $this->tool->consumer_key ?? '';
     }
 
     /**
@@ -111,11 +125,7 @@ class LtiData extends SimpleORMap
      */
     public function getConsumerSecret()
     {
-        if ($this->tool_id) {
-            return $this->tool->consumer_secret;
-        }
-
-        return $this->options['consumer_secret'];
+        return $this->tool->consumer_secret ?? '';
     }
 
     /**
@@ -123,11 +133,7 @@ class LtiData extends SimpleORMap
      */
     public function getOauthSignatureMethod()
     {
-        if ($this->tool_id) {
-            return $this->tool->oauth_signature_method;
-        }
-
-        return $this->options['oauth_signature_method'] ?? 'sha1';
+        return $this->tool->oauth_signature_method ?? 'sha1';
     }
 
     /**
@@ -135,11 +141,29 @@ class LtiData extends SimpleORMap
      */
     public function getCustomParameters()
     {
-        if ($this->tool_id) {
-            return $this->tool->custom_parameters . "\n" . $this->options['custom_parameters'];
+        $parameters = '';
+        if (!empty($this->tool->custom_parameters)) {
+            $parameters .= $this->tool->custom_parameters . "\n";
         }
+        $parameters .= $this->options['custom_parameters'] ?? '';
+        return $parameters;
+    }
 
-        return $this->options['custom_parameters'];
+    public function getCustomLtiParameterArray() : array
+    {
+        $parameter_str = $this->getCustomParameters();
+        if (empty($parameter_str)) {
+            return [];
+        }
+        $parameters = explode("\n", $parameter_str);
+        $array = [];
+        foreach ($parameters as $parameter) {
+            $key_value_parts = explode('=', $parameter, 2);
+            if (count($key_value_parts) === 2) {
+                $array[trim($key_value_parts[0])] = trim($key_value_parts[1]);
+            }
+        }
+        return ['https://purl.imsglobal.org/spec/lti/claim/custom' => $array];
     }
 
     /**
@@ -147,10 +171,17 @@ class LtiData extends SimpleORMap
      */
     public function getSendLisPerson()
     {
-        if ($this->tool_id) {
-            return $this->tool->send_lis_person;
-        }
+        return $this->tool->send_lis_person;
+    }
 
-        return $this->options['send_lis_person'];
+    /**
+     * Whether the LtiData instance uses its own (private) tool
+     * or one of the globally defined LTI tools.
+     *
+     * @return bool True, if the LtiData instance uses its own tool, false otherwise.
+     */
+    public function hasOwnTool() : bool
+    {
+        return $this->tool && !$this->tool->is_global;
     }
 }
diff --git a/lib/models/LtiGrade.php b/lib/models/LtiGrade.php
index 167ea58ced4..7afe456658e 100644
--- a/lib/models/LtiGrade.php
+++ b/lib/models/LtiGrade.php
@@ -16,7 +16,7 @@
  * @property float $score database column
  * @property int $mkdate database column
  * @property int $chdate database column
- * @property LtiData $link belongs_to LtiData
+ * @property LtiDeployment $link belongs_to LtiData
  * @property User $user belongs_to User
  */
 
@@ -30,7 +30,7 @@ class LtiGrade extends SimpleORMap
         $config['db_table'] = 'lti_grade';
 
         $config['belongs_to']['link'] = [
-            'class_name'  => LtiData::class,
+            'class_name'  => LtiDeployment::class,
             'foreign_key' => 'link_id'
         ];
         $config['belongs_to']['user'] = [
diff --git a/lib/models/LtiTool.php b/lib/models/LtiTool.php
index 53be2bc81b8..066df4666c0 100644
--- a/lib/models/LtiTool.php
+++ b/lib/models/LtiTool.php
@@ -1,4 +1,7 @@
 <?php
+
+use OAT\Library\Lti1p3Core\Tool\Tool;
+
 /**
  * LtiTool.php - LTI consumer API for Stud.IP
  *
@@ -16,13 +19,23 @@
  * @property string $consumer_key database column
  * @property string $consumer_secret database column
  * @property string $custom_parameters database column
+ * @property string $lti_version database column
+ * @property string $oidc_init_url database column
+ * @property string $oauth2_client_id database column
+ * @property string $jwks_url database column
+ * @property string $jwks_key_id database column
+ * @property string $deep_linking_url database column
+ * @property string $terms_of_use_url database column
+ * @property string $privacy_policy_url database column
+ * @property string $data_protection_notes database column
+ * @property string $range_id database column
  * @property int $allow_custom_url database column
  * @property int $deep_linking database column
  * @property int $send_lis_person database column
  * @property int $mkdate database column
  * @property int $chdate database column
  * @property string $oauth_signature_method database column
- * @property SimpleORMapCollection|LtiData[] $links has_many LtiData
+ * @property SimpleORMapCollection|LtiDeployment[] $links has_many LtiData
  */
 
 class LtiTool extends SimpleORMap
@@ -32,22 +45,151 @@ class LtiTool extends SimpleORMap
      */
     protected static function configure($config = [])
     {
-        $config['db_table'] = 'lti_tool';
+        $config['db_table'] = 'lti_tools';
 
         $config['has_many']['links'] = [
-            'class_name'        => LtiData::class,
+            'class_name'        => LtiDeployment::class,
             'assoc_foreign_key' => 'tool_id',
             'on_delete'         => 'delete'
         ];
 
+        $config['has_one']['oauth2_client'] = [
+            'class_name'        => \Studip\OAuth2\Models\Client::class,
+            'foreign_key'       => 'oauth2_client_id',
+            'on_delete'         => 'delete'
+        ];
+
         parent::configure($config);
     }
 
     /**
-     * Find all entries.
+     * Validates the data in the LtiTool instance.
+     *
+     * @return string[] An array with errors. The array is empty if all
+     *     fields are filled with valid data.
+     */
+    public function validate() : array
+    {
+        $errors = [];
+        if (!$this->name) {
+            $errors[] = _('Es wurde kein Name angegeben.');
+        }
+        if (!$this->launch_url) {
+            $errors[] = _('Es wurde keine Launch-URL angegeben.');
+        }
+        if (!in_array($this->lti_version, ['1.1', '1.3a'])) {
+            $errors[] = _('Die ausgewählte LTI-Version ist ungültig.');
+        }
+        if ($this->lti_version === '1.1') {
+            if (!$this->consumer_key) {
+                $errors[] = _('Es wurde kein Consumer-Key angegeben.');
+            }
+            if (!$this->consumer_secret) {
+                $errors[] = _('Es wurde kein Consumer-Secret angegeben.');
+            }
+        }
+        return $errors;
+    }
+
+    /**
+     * Retrieves all LTI tools.
+     *
+     * @param bool $with_private_tools Whether to include all private tools (true)
+     *     or not (false). Defautls to false.
+     *
+     * @return array A list of all LTI tools.
+     */
+    public static function findAll(bool $with_private_tools = false) : array
+    {
+        if ($with_private_tools) {
+            return self::findBySQL("1 ORDER BY name");
+        } else {
+            return self::findBySQL("`range_id` = 'global' ORDER BY name");
+        }
+    }
+
+    /**
+     * Checks whether a user may have the permissions to edit the tool.
+     *
+     * @param string $user_id The ID of the user whose edit permissions shall be checked.
+     *
+     * @return bool True, if the user may edit the tool, false otherwise.
+     */
+    public function isEditableByUser(string $user_id = null) : bool
+    {
+        $user_id ??= User::findCurrent()->id;
+        return $this->range_id === 'global' && $GLOBALS['perm']->have_perm('root')
+            || ($this->range_id !== 'global' && $GLOBALS['perm']->have_studip_perm('tutor', $this->range_id));
+    }
+
+    //ToolInterface implementation
+
+    public function getToolData() : Tool
+    {
+        return new Tool(
+            $this->id,
+            $this->name,
+            $this->launch_url,
+            $this->oidc_init_url,
+            $this->launch_url,
+            $this->deep_linking_url
+        );
+    }
+
+    /**
+     * Retrieves the keyring of the LTI tool or generates one, if explicitly requested.
+     *
+     * @param bool $generate Generates a new keyring for the tool if set to true.
+     *     Defaults to false.
+     *
+     * @return Keyring|null The keyring for the tool or null if no such keyring exists.
      */
-    public static function findAll()
+    public function getKeyring(bool $generate = false) : ?Keyring
+    {
+        $keyring = Keyring::findOneBySQL(
+            "`range_type` = 'lti_tool' AND `range_id` = :tool_id",
+            ['tool_id' => $this->id]
+        );
+        if ($generate && !$keyring) {
+            $keyring = Keyring::generate($this->id, 'lti_tool');
+        }
+        return $keyring;
+    }
+
+    /**
+     * Sets or updates the public key for the LTI tool.
+     *
+     * @param string $public_key The public key to set.
+     *
+     * @return bool True, if the public key could be set, false otherwise.
+     */
+    public function updatePublicKey(string $public_key) : bool
+    {
+        if (!$public_key) {
+            //No key? Then it cannot be set.
+            return false;
+        }
+        $keyring = $this->getKeyring();
+        if ($keyring) {
+            //Clear the fields for the passphrase and the private key:
+            $keyring->passphrase  = '';
+            $keyring->private_key = '';
+            //Store the new public key for the tool:
+            $keyring->public_key = $public_key;
+        } else {
+            $keyring = Keyring::createFromPublicKey($public_key, 'lti_tool', $this->id);
+        }
+        return $keyring->store() !== false;
+    }
+
+    public function getLtiVersionString() : string
     {
-        return self::findBySQL('1 ORDER BY name');
+        if ($this->lti_version === '1.3a') {
+            return '1.3a';
+        } elseif ($this->lti_version === '1.1') {
+            return '1.0/1.1';
+        } else {
+            return _('unbekannt');
+        }
     }
 }
diff --git a/lib/models/LtiToolPrivacySettings.php b/lib/models/LtiToolPrivacySettings.php
new file mode 100644
index 00000000000..040aba13705
--- /dev/null
+++ b/lib/models/LtiToolPrivacySettings.php
@@ -0,0 +1,46 @@
+<?php
+/**
+ * LtiToolPrivacySettings.php
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ *
+ * @author      Moritz Strohm <strohm@data-quest.de>
+ * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
+ * @since       6.0
+ */
+
+/**
+ * The LtiToolPrivacySettings class represents the privacy
+ * settings a user made for a specific LTI tool deployment.
+ * It not only stores the decision if the privacy statement of the tool
+ * has been accepted, but also which data may be transferred to the
+ * LTI tool.
+ *
+ * @property string deployment_id database column
+ * @property string user_id database column
+ * @property string accepted database column
+ * @property string allowed_optional_fields database column
+ * @property string mkdate database column
+ * @property string chdate database column
+ */
+class LtiToolPrivacySettings extends SimpleORMap
+{
+    protected static function configure($config = [])
+    {
+        $config['db_table'] = 'lti_tool_privacy_settings';
+
+        $config['belongs_to']['user'] = [
+            'class_name'  => User::class,
+            'foreign_key' => 'user_id'
+        ];
+        $config['belongs_to']['tool'] = [
+            'class_name'  => LtiTool::class,
+            'foreign_key' => 'tool_id'
+        ];
+
+        parent::configure($config);
+    }
+}
diff --git a/lib/modules/CoreParticipants.php b/lib/modules/CoreParticipants.php
index 1d1527253f0..54704958e82 100644
--- a/lib/modules/CoreParticipants.php
+++ b/lib/modules/CoreParticipants.php
@@ -19,6 +19,9 @@ class CoreParticipants extends CorePlugin implements StudipModule
         if ($user_id === 'nobody') {
             return null;
         }
+        if (CourseConfig::get()->LTI_SHARING_ENABLED) {
+            return null;
+        }
 
         $auto_insert_perm = Config::get()->AUTO_INSERT_SEM_PARTICIPANTS_VIEW_PERM;
         // show the participants-icon only if the course is not an auto-insert-sem
@@ -114,6 +117,9 @@ class CoreParticipants extends CorePlugin implements StudipModule
         if ($GLOBALS['user']->id === 'nobody') {
             return [];
         }
+        if (CourseConfig::get()->LTI_SHARING_ENABLED) {
+            return [];
+        }
 
         $navigation = new Navigation(_('Teilnehmende'));
         $navigation->setImage(Icon::create('persons', Icon::ROLE_INFO_ALT));
@@ -200,6 +206,6 @@ class CoreParticipants extends CorePlugin implements StudipModule
 
     public function isActivatableForContext(Range $context)
     {
-        return $context->getRangeType() === 'course';
+        return $context->getRangeType() === 'course' && !CourseConfig::get()->LTI_SHARING_ENABLED;
     }
 }
diff --git a/lib/modules/LtiToolModule.php b/lib/modules/LtiToolModule.php
index 507fc269750..d527f8d7972 100644
--- a/lib/modules/LtiToolModule.php
+++ b/lib/modules/LtiToolModule.php
@@ -10,7 +10,6 @@
  * @author      Elmar Ludwig
  * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
  */
-
 class LtiToolModule extends CorePlugin implements StudipModule, SystemPlugin, PrivacyPlugin
 {
     /**
@@ -28,7 +27,7 @@ class LtiToolModule extends CorePlugin implements StudipModule, SystemPlugin, Pr
             LtiGrade::deleteBySQL('user_id = ?', [$user->id]);
         });
         NotificationCenter::on('CourseDidDelete', function ($event, $course) {
-            LtiData::deleteBySQL('course_id = ?', [$course->id]);
+            LtiDeployment::deleteBySQL('course_id = ?', [$course->id]);
         });
     }
 
@@ -41,16 +40,13 @@ class LtiToolModule extends CorePlugin implements StudipModule, SystemPlugin, Pr
             return null;
         }
 
-        $title = CourseConfig::get($course_id)->LTI_TOOL_TITLE;
-        $changed = LtiData::countBySQL('course_id = ? AND chdate > ?', [$course_id, $last_visit]);
+        $changed = LtiDeployment::countBySQL('course_id = ? AND chdate > ?', [$course_id, $last_visit]);
 
-        $icon = $changed
-              ? Icon::create('link-extern', Icon::ROLE_NEW)
-              : Icon::create('link-extern');
+        $icon = Icon::create('link-extern', $changed ? Icon::ROLE_NEW : Icon::ROLE_CLICKABLE);
 
-        $navigation = new Navigation($title, 'dispatch.php/course/lti');
+        $navigation = new Navigation(_('LTI-Tools'), 'dispatch.php/course/lti');
         $navigation->setImage($icon);
-        $navigation->setLinkAttributes(['title' => $title]);
+        $navigation->setLinkAttributes(['title' => _('LTI-Tools')]);
 
         return $navigation;
     }
@@ -64,13 +60,12 @@ class LtiToolModule extends CorePlugin implements StudipModule, SystemPlugin, Pr
             return [];
         }
 
-        $title = CourseConfig::get($course_id)->LTI_TOOL_TITLE;
-        $grades = LtiData::countBySQL('course_id = ?', [$course_id]);
+        $grades = LtiDeployment::countBySQL('course_id = ?', [$course_id]);
 
-        $navigation = new Navigation($title);
+        $navigation = new Navigation(_('LTI-Tools'));
         $navigation->setImage(Icon::create('link-extern', Icon::ROLE_INFO_ALT));
         $navigation->setActiveImage(Icon::create('link-extern', Icon::ROLE_INFO));
-        $navigation->addSubNavigation('index', new Navigation($title, 'dispatch.php/course/lti'));
+        $navigation->addSubNavigation('index', new Navigation(_('LTI-Tools'), 'dispatch.php/course/lti'));
 
         if ($grades) {
             $navigation->addSubNavigation('grades', new Navigation(_('Ergebnisse'), 'dispatch.php/course/lti/grades'));
@@ -104,14 +99,11 @@ class LtiToolModule extends CorePlugin implements StudipModule, SystemPlugin, Pr
     public function getMetadata()
     {
         return [
-            'summary' => _('Verlinkung auf Inhalte in externen Anwendungen (LTI-Tool)'),
-            'description' => _('Dieses Modul bietet eine Möglichkeit zur Einbindung von externen Tools, '.
-                               'sofern diese den LTI-Standard unterstützen. Ähnlich wie bei der Seite '.
-                               '"Informationen" kann ein Titel sowie ein freier Text angegeben werden, der '.
-                               'den Nutzern zur Erläuterung angezeigt wird. Zur Einbindung von Inhalten aus '.
-                               'Fremdsystemen wird die LTI-Schnittstelle in der Version 1.x unterstützt.'),
+            'summary' => _('Anbindung von LTI-Tools'),
+            'description' => _('Mit diesem Werkzeug können LTI-Tools eingebunden werden, '.
+                               'sofern diese LTI in Version 1.0, 1.1 oder 1.3A unterstützen.'),
             'category' => _('Kommunikation und Zusammenarbeit'),
-            'keywords' => _('Einbindung von LTI-Tools (Version 1.x)'),
+            'keywords' => implode(';', ['LTI', _('LTI-Tools'), _('E-Learning')]),
             'icon' => Icon::create('link-extern', Icon::ROLE_INFO),
             'icon_clickable' => Icon::create('link-extern'),
             'screenshots' => [
@@ -119,7 +111,8 @@ class LtiToolModule extends CorePlugin implements StudipModule, SystemPlugin, Pr
                 'pictures' => [
                     ['source' => 'Lti_tool_demo.jpg', 'title' => 'Beispiel für Wordpress-Einbindung']
                 ]
-            ]
+            ],
+            'displayname' => _('LTI-Tools')
         ];
     }
 }
diff --git a/resources/assets/javascripts/bootstrap/studip_helper_attributes.js b/resources/assets/javascripts/bootstrap/studip_helper_attributes.js
index 26cdd9c78b1..54f8da39fd5 100644
--- a/resources/assets/javascripts/bootstrap/studip_helper_attributes.js
+++ b/resources/assets/javascripts/bootstrap/studip_helper_attributes.js
@@ -133,7 +133,7 @@ STUDIP.ready((event) => {
 
 //
 $(document).on('change', '[data-hides],[data-shows]', function () {
-    if (!$(this).is(':checkbox,:radio')) {
+    if (!$(this).is(':checkbox,:radio,select')) {
         return;
     }
 
@@ -142,14 +142,40 @@ $(document).on('change', '[data-hides],[data-shows]', function () {
         if (selector === undefined || $(this).prop('disabled')) {
             return;
         }
-
-        var state = $(this).prop('checked') || $(this).prop('indeterminate') || false;
+        let triggering_value = undefined;
+        if ($(this).is('select')) {
+            triggering_value = $(this).data('triggering-value').toString();
+        }
+        let state = $(this).prop('checked') || $(this).prop('indeterminate') || $(this).val().toString() === triggering_value || false;
         $(selector).each(function() {
             var condition = $(this).data(`${type}Condition`),
                 toggle = state && (!condition || $(condition).length > 0);
             $(this)
                 .toggle(type === 'shows' ? toggle : !toggle)
                 .trigger('update.proxy');
+            //Check if there are required fields that become hidden or the other way around.
+            //Hidden fields must not be required.
+            let elements = $(this).find('input[required]');
+            if ($(this).attr('required')) {
+                //Append the element itself:
+                $(elements).append($(this));
+            }
+            $(elements).each(function(index, element) {
+
+                let hide = (type === 'shows' && !condition) || (type === 'hides' && condition);
+                if (hide) {
+                    //Remove the required attribute:
+                    if ($(element).attr('required') !== 'false') {
+                        $(element).attr('data-is_required', '1');
+                        $(element).removeAttr('required');
+                    }
+                } else {
+                    //Set the required attribute:
+                    if ($(element).attr('data-is_required')) {
+                        $(element).attr('required', 'required');
+                    }
+                }
+            });
         });
     });
 });
-- 
GitLab