diff --git a/.gitignore b/.gitignore
index 1f1829da0fa7d599026b250a712dbf0d2995ea20..869656cf596007becd9c3fd3b1eaedecc0522ab8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,3 +46,5 @@ tests/_helpers/_generated
 tests/_output/
 
 .idea
+/config/oauth2/*.key
+/config/oauth2/encryption_key.php
diff --git a/app/controllers/admin/oauth2.php b/app/controllers/admin/oauth2.php
new file mode 100644
index 0000000000000000000000000000000000000000..ae4f5f9c760e78ed4d1d313c6a805c22307334fa
--- /dev/null
+++ b/app/controllers/admin/oauth2.php
@@ -0,0 +1,46 @@
+<?php
+
+use Studip\OAuth2\Container;
+use Studip\OAuth2\Models\Client;
+use Studip\OAuth2\SetupInformation;
+
+class Admin_Oauth2Controller extends AuthenticatedController
+{
+    /**
+     * @param string $action
+     * @param string[] $args
+     *
+     * @return void
+     */
+    public function before_filter(&$action, &$args)
+    {
+        parent::before_filter($action, $args);
+
+        $GLOBALS['perm']->check('root');
+
+        Navigation::activateItem('/admin/config/oauth2');
+        PageLayout::setTitle(_('OAuth2 Verwaltung'));
+
+        $this->types = [
+            'website' => _('Website'),
+            'desktop' => _('Herkömmliches Desktopprogramm'),
+            'mobile' => _('Mobile App'),
+        ];
+
+        // Sidebar
+        $views = new ViewsWidget();
+        $views->addLink(
+            _('Übersicht'),
+            $this->indexURL()
+        )->setActive($action === 'index');
+        Sidebar::get()->addWidget($views);
+
+        $this->container = new Container();
+    }
+
+    public function index_action(): void
+    {
+        $this->setup = $this->container->get(SetupInformation::class);
+        $this->clients = Client::findBySql('1 ORDER BY chdate DESC');
+    }
+}
diff --git a/app/controllers/api/oauth2/applications.php b/app/controllers/api/oauth2/applications.php
new file mode 100644
index 0000000000000000000000000000000000000000..d08ec1e9bdaeb78b487133b643b630022e576d92
--- /dev/null
+++ b/app/controllers/api/oauth2/applications.php
@@ -0,0 +1,101 @@
+<?php
+use Studip\OAuth2\Models\AccessToken;
+use Studip\OAuth2\Models\Scope;
+
+/**
+ * @property array $applications
+ */
+class Api_Oauth2_ApplicationsController extends AuthenticatedController
+{
+    public function index_action(): void
+    {
+        Navigation::activateItem('/profile/settings/oauth2');
+        PageLayout::setTitle(_('Autorisierte Drittanwendungen'));
+        Helpbar::get()->addPlainText(
+            _('Autorisierte Drittanwendungen'),
+            _("Sie können Ihren Stud.IP-Zugang über OAuth mit Anwendungen von Drittanbietern verbinden.\n\nWenn Sie eine OAuth-App autorisieren, sollten Sie sicherstellen, dass Sie der Anwendung vertrauen, überprüfen, wer sie entwickelt hat, und die Art der Informationen überprüfen, auf die die Anwendung zugreifen möchte.")
+        );
+
+        $user = User::findCurrent();
+        $this->applications = $this->getApplications($user);
+    }
+
+    public function details_action(AccessToken $accessToken): void
+    {
+        $user = User::findCurrent();
+        if ($accessToken['user_id'] !== $user->id) {
+            throw new AccessDeniedException();
+        }
+
+        PageLayout::setTitle(_('Autorisierte OAuth2-Drittanwendung'));
+        $this->application = $this->formatApplication($accessToken);
+
+        if (!$this->application) {
+            throw new Trails_Exception(500, 'Error finding client.');
+        }
+    }
+
+    public function revoke_action(): void
+    {
+        CSRFProtection::verifyUnsafeRequest();
+
+        $user = User::findCurrent();
+        $accessToken = AccessToken::find(Request::option('application'));
+        if (!$accessToken) {
+            throw new Trails_Exception(404);
+        }
+        if ($accessToken['user_id'] !== $user->id) {
+            throw new AccessDeniedException();
+        }
+
+        $accessToken->revoke();
+
+        $this->redirect('api/oauth2/applications');
+    }
+
+    private function getApplications(User $user): array
+    {
+        return array_reduce(
+            AccessToken::findValidTokens($user),
+            function ($applications, $accessToken) {
+                $application = $this->formatApplication($accessToken);
+                if ($application) {
+                    $applications[] = $application;
+                }
+
+                return $applications;
+            },
+            []
+        );
+    }
+
+    private function formatApplication(AccessToken $accessToken): ?array
+    {
+        $allScopes = Scope::scopes();
+
+        if (!$accessToken->client) {
+            return null;
+        }
+
+        return [
+            'id'          => $accessToken['id'],
+            'name'        => $accessToken->client['name'],
+            'description' => $accessToken->client['description'],
+            'owner'       => $accessToken->client['owner'],
+            'homepage'    => $accessToken->client['homepage'],
+            'created'     => new DateTime('@' . $accessToken->client['mkdate']),
+
+            'scopes' => array_reduce(
+                json_decode($accessToken['scopes']),
+                function ($scopes, $scopeIdentifier) use ($allScopes) {
+                    if (isset($allScopes[$scopeIdentifier])) {
+                        $scopes[] = $allScopes[$scopeIdentifier];
+                    }
+
+                    return $scopes;
+                },
+                []
+            )
+        ];
+    }
+}
diff --git a/app/controllers/api/oauth2/authorize.php b/app/controllers/api/oauth2/authorize.php
new file mode 100644
index 0000000000000000000000000000000000000000..5628d49563dfa1e058fd7999aca8fb21e458b8b0
--- /dev/null
+++ b/app/controllers/api/oauth2/authorize.php
@@ -0,0 +1,175 @@
+<?php
+require_once __DIR__ . '/oauth2_controller.php';
+
+use League\OAuth2\Server\RequestTypes\AuthorizationRequest;
+use Studip\OAuth2\Bridge\UserEntity;
+use Studip\OAuth2\Exceptions\InvalidAuthTokenException;
+use Studip\OAuth2\Models\Scope;
+
+class Api_Oauth2_AuthorizeController extends OAuth2Controller
+{
+    public function before_filter(&$action, &$args)
+    {
+        parent::before_filter($action, $args);
+
+        if ('index' !== $action) {
+            throw new Trails_Exception(404);
+        }
+
+        $action = $this->determineAction();
+    }
+
+    private function determineAction(): string
+    {
+        $method = $this->getMethod();
+
+        if (Request::submitted('auth_token')) {
+            $GLOBALS['auth']->login_if('nobody' === $GLOBALS['user']->id);
+            CSRFProtection::verifyUnsafeRequest();
+
+            switch ($method) {
+                case 'POST':
+                    return 'approved';
+
+                case 'DELETE':
+                    return 'denied';
+            }
+        }
+
+        return 'authorize';
+    }
+
+    public function authorize_action(): void
+    {
+        $psrRequest = $this->getPsrRequest();
+        $authRequest = $this->server->validateAuthorizationRequest($psrRequest);
+
+        $scopes = $authRequest->getScopes();
+        $client = $authRequest->getClient();
+
+        $authToken = randomString(32);
+        $this->freezeSessionVars($authRequest, $authToken);
+
+        // show login form if not logged in
+        $authPlugin = Config::get()->getValue('API_OAUTH_AUTH_PLUGIN');
+        if ('nobody' === $GLOBALS['user']->id && 'Standard' !== $authPlugin && !Request::option('sso')) {
+            $queryParams = $psrRequest->getQueryParams();
+            $queryParams['sso'] = strtolower($authPlugin);
+            $this->redirect($this->authorizeURL($queryParams));
+
+            return;
+        } else {
+            $GLOBALS['auth']->login_if('nobody' === $GLOBALS['user']->id);
+        }
+
+        $this->client = $client;
+        $this->user = $GLOBALS['user'];
+        $this->scopes = $this->scopesFor($scopes);
+        $this->authToken = $authToken;
+        $this->state = $authRequest->getState();
+
+        PageLayout::disableHeader();
+        $this->render_template(
+            'api/oauth2/authorize.php',
+            $GLOBALS['template_factory']->open('layouts/base.php')
+        );
+    }
+
+    public function approved_action(): void
+    {
+        [$authRequest, $authToken] = $this->thawSessionVars();
+        $this->assertValidAuthToken($authToken);
+
+        $authRequest->setUser(new UserEntity($GLOBALS['user']->id));
+        $authRequest->setAuthorizationApproved(true);
+
+        $response = $this->server->completeAuthorizationRequest($authRequest, $this->getPsrResponse());
+
+        $this->renderPsrResponse($response);
+    }
+
+    public function denied_action(): void
+    {
+        [$authRequest, $authToken] = $this->thawSessionVars();
+        $this->assertValidAuthToken($authToken);
+
+        $authRequest->setUser(new UserEntity($GLOBALS['user']->id));
+        $authRequest->setAuthorizationApproved(false);
+
+        $clientUris = $authRequest->getClient()->getRedirectUri();
+
+        $uri = $authRequest->getRedirectUri();
+        if (!in_array($uri, $clientUris)) {
+            $uri = current($clientUris);
+        }
+
+        $uri = URLHelper::getURL($uri, [
+            'error' => 'access_denied',
+            'state' => Request::get('state'),
+        ], true);
+        $this->redirect($uri);
+    }
+
+    private function getMethod(): string
+    {
+        $method = Request::method();
+        if ('POST' === $method && Request::submitted('_method')) {
+            $_method = strtoupper(Request::get('_method'));
+            if (in_array($_method, ['DELETE', 'PATCH', 'PUT'])) {
+                $method = $_method;
+            }
+        }
+
+        return $method;
+    }
+
+    /**
+     * Make sure the auth token matches the one in the session.
+     *
+     * @throws InvalidAuthTokenException
+     */
+    private function assertValidAuthToken(string $authToken): void
+    {
+        if (Request::submitted('auth_token') && $authToken !== Request::get('auth_token')) {
+            throw InvalidAuthTokenException::different();
+        }
+    }
+
+    private function freezeSessionVars(AuthorizationRequest $authRequest, string $authToken): void
+    {
+        $_SESSION['oauth2'] = [
+            'authRequest' => serialize($authRequest),
+            'authToken'   => $authToken,
+        ];
+    }
+
+    private function thawSessionVars(): array
+    {
+        $authRequest = null;
+        $authToken = null;
+        if (
+            isset($_SESSION['oauth2']) &&
+            is_array($_SESSION['oauth2']) &&
+            isset($_SESSION['oauth2']['authRequest']) &&
+            isset($_SESSION['oauth2']['authToken'])
+        ) {
+            $authRequest = unserialize($_SESSION['oauth2']['authRequest']);
+            $authToken = $_SESSION['oauth2']['authToken'];
+        }
+
+        return [$authRequest, $authToken];
+    }
+
+    private function scopesFor(array $scopeEntities): array
+    {
+        $scopes = Scope::scopes();
+        $scopeModels = [];
+        foreach ($scopeEntities as $scopeEntity) {
+            if (isset($scopes[$scopeEntity->getIdentifier()])) {
+                $scopeModels[] = $scopes[$scopeEntity->getIdentifier()];
+            }
+        }
+
+        return $scopeModels;
+    }
+}
diff --git a/app/controllers/api/oauth2/clients.php b/app/controllers/api/oauth2/clients.php
new file mode 100644
index 0000000000000000000000000000000000000000..c16b67dabccc297f03e1c7377420c9e5c325b048
--- /dev/null
+++ b/app/controllers/api/oauth2/clients.php
@@ -0,0 +1,166 @@
+<?php
+
+use Studip\OAuth2\Models\Client;
+
+class Api_Oauth2_ClientsController extends AuthenticatedController
+{
+    /**
+     * @param string $action
+     * @param string[] $args
+     *
+     * @return void
+     */
+    public function before_filter(&$action, &$args)
+    {
+        parent::before_filter($action, $args);
+
+        $GLOBALS['perm']->check('root');
+    }
+
+    public function add_action(): void
+    {
+        Navigation::activateItem('/admin/config/oauth2');
+        PageLayout::setTitle(_('OAuth2-Client hinzufügen'));
+    }
+
+    public function store_action(): void
+    {
+        CSRFProtection::verifyUnsafeRequest();
+
+        $this->redirect('admin/oauth2');
+
+        list($valid, $data, $errors) = $this->validateCreateClientRequest();
+
+        if (!$valid) {
+            PageLayout::postError(_('Das Erstellen eines OAuth2-Clients war nicht erfolgreich.'), $errors);
+            return;
+        }
+
+        $client = $this->createAuthCodeClient($data);
+        $this->outputClientCredentials($client);
+    }
+
+    public function delete_action(Client $client): void
+    {
+        CSRFProtection::verifyUnsafeRequest();
+
+        $clientId = $client['id'];
+        $clientName = $client['name'];
+        $client->delete();
+
+        PageLayout::postSuccess(sprintf(_('Der OAuth2-Client #%d ("%s") wurde gelöscht.'), $clientId, $clientName));
+        $this->redirect('admin/oauth2');
+    }
+
+    /**
+     * Create a authorization code client.
+     *
+     * @param array<string, mixed>
+     */
+    private function createAuthCodeClient(array $data): Client
+    {
+        return Client::createClient(
+            $data['name'],
+            $data['redirect'],
+            $data['confidential'],
+            $data['owner'],
+            $data['homepage'],
+            $data['description'],
+            $data['admin_notes']
+        );
+    }
+
+    /**
+     * Show feedback to the user depending on the confidentiality of the `$client`.
+     */
+    private function outputClientCredentials(Client $client): void
+    {
+        if ($client->confidential()) {
+            PageLayout::postWarning(_('Der OAuth2-Client wurde erstellt.'), [
+                sprintf(_('Die <em lang="en"> client_id </em> lautet: <pre>%s</pre>'), $client['id']),
+                sprintf(_('Das <em lang="en"> client_secret </em> lautet: <pre>%s</pre>'), $client->plainsecret),
+                _(
+                    'Notieren Sie sich bitte das <em lang="en"> client_secret </em>. Es wird Ihnen nur <strong> dieses eine Mal </strong> angezeigt.'
+                ),
+            ]);
+        } else {
+            PageLayout::postSuccess(_('Der OAuth2-Client wurde erstellt.'), [
+                sprintf(_('Die <em lang="en"> client_id </em> lautet: <pre>%s</pre>'), $client['id']),
+            ]);
+        }
+    }
+
+    /**
+     * Validate the request parameters when creating a new client.
+     *
+     * @return array{0: bool, 1: array<string, mixed>, 2: string[]}
+     */
+    private function validateCreateClientRequest()
+    {
+        $valid = true;
+        $data = [];
+        $errors = [];
+
+        // required
+        $name = Request::get('name');
+        $redirectURIs = Request::get('redirect');
+        $confidentiality = Request::get('confidentiality');
+        $owner = Request::get('owner');
+        $homepage = Request::get('homepage');
+
+        // optional
+        $data['description'] = Request::get('description');
+        $data['admin_notes'] = Request::get('admin_notes');
+
+        foreach (compact('name', 'redirectURIs', 'confidentiality', 'owner', 'homepage') as $key => $value) {
+            if (!isset($value)) {
+                $errors[] = sprintf(_('Parameter "%s" fehlt.'), $key);
+                $valid = false;
+            }
+        }
+
+        // validate $name
+        $data['name'] = trim($name);
+        if ($name === '') {
+            $errors[] = _('Der Parameter "name" darf nicht leer sein.');
+            $valid = false;
+        }
+
+        // validate $redirectURIS
+        $redirect = [];
+        $redirectLines = preg_split("/[\n\r]/", $redirectURIs, -1, PREG_SPLIT_NO_EMPTY);
+        foreach ($redirectLines as $line) {
+            $url = filter_var($line, FILTER_SANITIZE_URL);
+            if (false === filter_var($url, FILTER_VALIDATE_URL)) {
+                $errors = _('Der Parameter "redirect" darf nur gültige URLs enthalten.');
+                $valid = false;
+                break;
+            }
+            $redirect[] = $url;
+        }
+        $data['redirect'] = join(',', $redirect);
+
+        // validate $confidentiality
+        if (!in_array($confidentiality, ['public', 'confidential'])) {
+            $errors[] = _('Der Parameter "confidentiality" darf nur gültige URLs enthalten.');
+            $valid = false;
+        }
+        $data['confidential'] = $confidentiality === 'confidential';
+
+        // validate $owner
+        $data['owner'] = trim($owner);
+        if ($owner === '') {
+            $errors[] = _('Der Parameter "owner" darf nicht leer sein.');
+            $valid = false;
+        }
+
+        // validate $homepage
+        $data['homepage'] = filter_var($homepage, FILTER_SANITIZE_URL);
+        if (false === filter_var($homepage, FILTER_VALIDATE_URL)) {
+            $errors = _('Der Parameter "homepage" muss eine gültige URL enthalten.');
+            $valid = false;
+        }
+
+        return [$valid, $data, $errors];
+    }
+}
diff --git a/app/controllers/api/oauth2/oauth2_controller.php b/app/controllers/api/oauth2/oauth2_controller.php
new file mode 100644
index 0000000000000000000000000000000000000000..fd02ea9ee19384fb7cedf07fe2a4adcf6837dd90
--- /dev/null
+++ b/app/controllers/api/oauth2/oauth2_controller.php
@@ -0,0 +1,52 @@
+<?php
+
+use League\OAuth2\Server\AuthorizationServer;
+use League\OAuth2\Server\Exception\OAuthServerException;
+use Studip\OAuth2\NegotiatesWithPsr7;
+
+abstract class OAuth2Controller extends StudipController
+{
+    use NegotiatesWithPsr7;
+
+    /**
+     * @return void
+     */
+    public function before_filter(&$action, &$args)
+    {
+        parent::before_filter($action, $args);
+
+        page_open([
+            'sess' => 'Seminar_Session',
+            'auth' => 'Seminar_Default_Auth',
+            'perm' => 'Seminar_Perm',
+            'user' => 'Seminar_User',
+        ]);
+
+        $this->set_layout(null);
+
+        $this->container = new Studip\OAuth2\Container();
+        $this->server = $this->getAuthorizationServer();
+    }
+
+    /**
+     * Exception handler called when the performance of an action raises an
+     * exception.
+     *
+     * @param Exception $exception the thrown exception
+     */
+    public function rescue($exception)
+    {
+        if ($exception instanceof OAuthServerException) {
+            $psrResponse = $exception->generateHttpResponse($this->getPsrResponse());
+
+            return $this->convertPsrResponse($psrResponse);
+        }
+
+        return new Trails_Response($exception->getMessage(), [], 500);
+    }
+
+    protected function getAuthorizationServer(): AuthorizationServer
+    {
+        return $this->container->get(AuthorizationServer::class);
+    }
+}
diff --git a/app/controllers/api/oauth2/token.php b/app/controllers/api/oauth2/token.php
new file mode 100644
index 0000000000000000000000000000000000000000..0ae7ffbd2d87fe3bf73155e9568421495bc5c5c4
--- /dev/null
+++ b/app/controllers/api/oauth2/token.php
@@ -0,0 +1,29 @@
+<?php
+require_once __DIR__ . '/oauth2_controller.php';
+
+class Api_Oauth2_TokenController extends OAuth2Controller
+{
+    public function before_filter(&$action, &$args)
+    {
+        parent::before_filter($action, $args);
+
+        if ('index' !== $action) {
+            throw new Trails_Exception(404);
+        }
+
+        if (!Request::isPost()) {
+            throw new Trails_Exception(405);
+        }
+
+        $action = 'issue_token';
+    }
+
+    public function issue_token_action(): void
+    {
+        $psrRequest = $this->getPsrRequest();
+        $psrResponse = $this->getPsrResponse();
+        $response = $this->server->respondToAccessTokenRequest($psrRequest, $psrResponse);
+
+        $this->renderPsrResponse($response);
+    }
+}
diff --git a/app/views/admin/oauth2/_clients.php b/app/views/admin/oauth2/_clients.php
new file mode 100644
index 0000000000000000000000000000000000000000..0e3db8aaa3bc9a598af04bb87869f09fc80103af
--- /dev/null
+++ b/app/views/admin/oauth2/_clients.php
@@ -0,0 +1,79 @@
+<?
+    $sidebar = Sidebar::get();
+    $actions = new ActionsWidget();
+    $actions->addLink(
+        _('OAuth2-Client hinzufügen'),
+        $controller->url_for('api/oauth2/clients/add'),
+        Icon::create('add')
+    );
+    $sidebar->addWidget($actions);
+?>
+
+<? if (isset($clients) && count($clients)) { ?>
+    <h2>
+        <?= _('Registrierte OAuth2-Clients') ?>
+    </h2>
+
+    <? foreach ($clients as $client) { ?>
+        <article class="studip">
+            <header>
+                <h1>
+                    <b><?= htmlReady($client['name']) ?></b>
+                </h1>
+                <nav>
+                    <form
+                        action ="<?= $controller->link_for('api/oauth2/clients/delete', $client) ?>"
+                        method="post">
+                        <?= CSRFProtection::tokenTag() ?>
+                        <?= ActionMenu::get()
+                                      ->addButton(
+                                          sprintf(_('OAuth2-Client "%s" löschen'), $client['name']),
+                                          'delete_client',
+                                          Icon::create('trash'),
+                                          [
+                                              'data-confirm' => _('Wollen Sie den OAuth2-Client wirklich löschen?'),
+                                              'title' => sprintf(_('OAuth2-Client "%s" löschen'), $client['name']),
+                                          ]
+                                      )
+                                      ->render() ?>
+                    </form>
+                </nav>
+            </header>
+
+            <div>
+                <dl>
+                    <dt><?= _('Beschreibung') ?></dt>
+                    <dd><?= htmlReady($client['description']) ?></dd>
+
+                    <dt><?= _('Entwickelt durch') ?></dt>
+                    <dd>
+                        <a rel="noreferrer noopener" target="_blank"
+                            href="<?= htmlReady($client['homepage']) ?>">
+                            <?= htmlReady($client['owner']) ?>
+                        </a>
+                    </dd>
+
+                    <dt><?= _('client_id') ?></dt>
+                    <dd> <?= htmlReady($client['id']) ?> </dd>
+
+                    <dt><?= _('Redirect-URIs') ?></dt>
+                    <dd>
+                        <ul>
+                            <? foreach ($client->redirectUris() as $uri) { ?>
+                                <li><?= htmlReady($uri) ?></li>
+                            <? } ?>
+                        </ul>
+                    </dd>
+
+                    <dt><?= _('Kann kryptographische Geheimnisse bewahren?') ?></dt>
+                    <dd><?= $client->confidential() ? _('Ja') : _('Nein') ?></dd>
+
+                    <dt><?= _('Notizen (nur für Root-Accounts sichtbar)') ?></dt>
+                    <dd>
+                        <?= htmlReady($client['admin_notes']) ?>
+                    </dd>
+                </dl>
+            </div>
+        </article>
+    <? } ?>
+<? } ?>
diff --git a/app/views/admin/oauth2/_notices.php b/app/views/admin/oauth2/_notices.php
new file mode 100644
index 0000000000000000000000000000000000000000..a67539c4e7c08f5f7ad52c9d467dfae05a2457c7
--- /dev/null
+++ b/app/views/admin/oauth2/_notices.php
@@ -0,0 +1,10 @@
+<? if (!isset($clients) || !count($clients)) { ?>
+    <?= MessageBox::info(
+        _('Es wurde noch kein OAuth2-Client erstellt.') .
+        '<br/>' .
+        \Studip\LinkButton::createAdd(
+            _('OAuth2-Client hinzufügen'),
+            $controller->link_for('api/oauth2/clients/add')
+        )
+        ) ?>
+<? } ?>
diff --git a/app/views/admin/oauth2/_setup.php b/app/views/admin/oauth2/_setup.php
new file mode 100644
index 0000000000000000000000000000000000000000..2469a1b63b221b20351569f01fd1a5f22ca96b52
--- /dev/null
+++ b/app/views/admin/oauth2/_setup.php
@@ -0,0 +1,17 @@
+<ul>
+    <li>
+        <? $privateKey = $setup->privateKey(); ?>
+        <b lang="en">Private Key</b> (<?= htmlReady($privateKey->filename()) ?>)
+        <?= $this->render_partial('admin/oauth2/_setup_key.php', ['key' => $privateKey]) ?>
+    </li>
+    <li>
+        <? $publicKey = $setup->publicKey(); ?>
+        <b lang="en">Public Key</b> (<?= htmlReady($publicKey->filename()) ?>)
+        <?= $this->render_partial('admin/oauth2/_setup_key.php', ['key' => $publicKey]) ?>
+    </li>
+    <li>
+        <? $encryptionKey = $setup->encryptionKey(); ?>
+        <b lang="en">Encryption Key</b> (<?= htmlReady($encryptionKey->filename()) ?>)
+        <?= $this->render_partial('admin/oauth2/_setup_key.php', ['key' => $encryptionKey]) ?>
+    </li>
+</ul>
diff --git a/app/views/admin/oauth2/_setup_key.php b/app/views/admin/oauth2/_setup_key.php
new file mode 100644
index 0000000000000000000000000000000000000000..d1ee3229c99c8a4e83f8668d27a60d841541fbe4
--- /dev/null
+++ b/app/views/admin/oauth2/_setup_key.php
@@ -0,0 +1,28 @@
+<?php
+    $checkmark = function (bool $checked): Icon {
+        return $checked
+             ? Icon::create('accept', Icon::ROLE_STATUS_GREEN)
+             : Icon::create('decline', Icon::ROLE_STATUS_RED);
+    };
+
+    $predicate = function ($checked, $positive, $negative) {
+        return $checked ? $positive : $negative;
+    };
+?>
+<ul>
+    <li style="list-style-image: url(<?= $checkmark($key->exists())->asImagePath() ?>)">
+        <?= $predicate($key->exists(), _('Datei existiert.'), _('Datei existiert nicht.')) ?>
+    </li>
+    <li style="list-style-image: url(<?= $checkmark($key->isReadable())->asImagePath() ?>)">
+        <?= $predicate($key->isReadable(), _('Datei ist lesbar.'), _('Datei ist nicht lesbar.')) ?>
+    </li>
+    <? if ($key->isReadable()) { ?>
+        <li style="list-style-image: url(<?= $checkmark($key->hasProperMode())->asImagePath() ?>)">
+            <?= $predicate(
+                $key->hasProperMode(),
+                sprintf(_('Korrekte Zugriffsberechtigung: %s'), $key->mode()),
+                sprintf(_('Falsche Zugriffsberechtigung: %s'), $key->mode())
+            ) ?>
+        </li>
+    <? } ?>
+</ul>
diff --git a/app/views/admin/oauth2/index.php b/app/views/admin/oauth2/index.php
new file mode 100644
index 0000000000000000000000000000000000000000..8ac0f188e15b1c2fecdb3a6205db8e7fdd9b4ffe
--- /dev/null
+++ b/app/views/admin/oauth2/index.php
@@ -0,0 +1,14 @@
+<?= $this->render_partial('admin/oauth2/_notices') ?>
+
+<article class="studip admin-oauth2--setup">
+    <header>
+        <h1>
+            <a name="setup">
+                <?= _('OAuth2-Setup') ?>
+            </a>
+        </h1>
+    </header>
+    <?= $this->render_partial('admin/oauth2/_setup') ?>
+</article>
+
+<?= $this->render_partial('admin/oauth2/_clients') ?>
diff --git a/app/views/api/oauth/authorize.php b/app/views/api/oauth/authorize.php
index 8330e9f658d1d4019d0d39c5955094f2b177b4ac..6c665328ad29675331b8dab3bdaa39aad8e96693 100644
--- a/app/views/api/oauth/authorize.php
+++ b/app/views/api/oauth/authorize.php
@@ -23,11 +23,12 @@
             htmlReady($GLOBALS['user']->username)
         ) ?><br>
         <small>
-            <?= sprintf(
-                _('Sind sie nicht <strong>%s</strong>, so <a href="%s">melden Sie sich bitte ab</a> und versuchen es erneut.'),
-                htmlReady($GLOBALS['user']->getFullName()),
-                URLHelper::getLink('logout.php')
-            ) ?>
+            <a href="<?= URLHelper::getLink('logout.php') ?>">
+                <?= sprintf(
+                    _('Sind sie nicht <strong>%s</strong>, so melden Sie sich bitte ab und versuchen es erneut.'),
+                    htmlReady($GLOBALS['user']->getFullName())
+                ) ?>
+            </a>
         </small>
     </p>
 </section>
diff --git a/app/views/api/oauth2/applications/details.php b/app/views/api/oauth2/applications/details.php
new file mode 100644
index 0000000000000000000000000000000000000000..12533a07543f0bf17313415c7aa495ad3662f149
--- /dev/null
+++ b/app/views/api/oauth2/applications/details.php
@@ -0,0 +1,24 @@
+<dl>
+    <dt><?= _('Name') ?></dt>
+    <dl><?= htmlReady($application['name']) ?></dl>
+
+    <dt><?= _('Beschreibung') ?></dt>
+    <dl><?= htmlReady($application['description']) ?></dl>
+
+    <dt><?= _('Von wem wird der OAuth2-Client entwickelt?') ?></dt>
+    <dl>
+        <a rel="noreferrer noopener" target="_blank"
+            href="<?= htmlReady($application['homepage']) ?>">
+            <?= htmlReady($application['owner']) ?>
+        </a>
+    </dl>
+
+    <dt><?= _('Berechtigungen') ?></dt>
+    <dd>
+        <ul>
+            <? foreach ($application['scopes'] as $scope) { ?>
+                <li><?= htmlReady($scope->description) ?></li>
+            <? } ?>
+        </ul>
+    </dd>
+</dl>
diff --git a/app/views/api/oauth2/applications/index.php b/app/views/api/oauth2/applications/index.php
new file mode 100644
index 0000000000000000000000000000000000000000..6a1462a538069ad0ec8975a79b33bb3e0c1251b1
--- /dev/null
+++ b/app/views/api/oauth2/applications/index.php
@@ -0,0 +1,52 @@
+<? if (isset($applications) && count($applications)) { ?>
+    <? foreach ($applications as $application) { ?>
+        <article class="studip">
+            <header>
+                <h1>
+                    <a href="<?= $controller->link_for('api/oauth2/applications/details/' . $application['id']) ?>" data-dialog="size=auto">
+                        <?= htmlReady($application['name']) ?>
+                    </a>
+                </h1>
+                <nav>
+                    <form
+                        action ="<?= $controller->link_for('api/oauth2/applications/revoke') ?>"
+                        method="post">
+                        <?= CSRFProtection::tokenTag() ?>
+                        <input type="hidden" name="application" value="<?= htmlReady($application['id']) ?>">
+                        <?= ActionMenu::get()
+                                      ->addButton(
+                                          _('Autorisierung widerrufen'),
+                                          'revoke_authorisation',
+                                          Icon::create('trash'),
+                                          [
+                                              'data-confirm' => _('Wollen Sie die OAuth2-Autorisierung wirklich widerrufen?'),
+                                              'title' => _('Autorisierung widerrufen'),
+                                          ]
+                                      )
+                                      ->render() ?>
+                    </form>
+                </nav>
+            </header>
+
+            <div>
+                <span class="oauth2-application--owned-by">
+                    <?= _('Entwickelt durch:') ?>
+                    <a rel="noreferrer noopener" target="_blank"
+                        href="<?= htmlReady($application['homepage']) ?>">
+                        <?= htmlReady($application['owner']) ?>
+                    </a>
+                </span>
+            </div>
+
+            <ul>
+                <? foreach ($application['scopes'] as $scope) { ?>
+                    <li><?= htmlReady($scope->description) ?></li>
+                <? } ?>
+            </ul>
+        </article>
+    <? } ?>
+<? } else { ?>
+    <?= \MessageBox::info(
+        _('Keine autorisierten Drittanwendungen'),
+        [ _('Sie haben keine Anwendungen, die zum Zugriff auf Ihr Konto berechtigt sind.') ]) ?>
+<? } ?>
diff --git a/app/views/api/oauth2/authorize.php b/app/views/api/oauth2/authorize.php
new file mode 100644
index 0000000000000000000000000000000000000000..693968adf86e818596b56e9280e09cf31718dbac
--- /dev/null
+++ b/app/views/api/oauth2/authorize.php
@@ -0,0 +1,60 @@
+<section class="oauth authorize">
+    <header>
+        <h1><?= _('Autorisierungsanfrage') ?></h1>
+    </header>
+
+    <p>
+        <?= sprintf(
+            _('Die Applikation <strong>"%s"</strong> möchte auf Ihre Daten zugreifen.'),
+            htmlReady($client->getName())
+            ) ?>
+    </p>
+
+    <? if (count($scopes) > 0) { ?>
+        <div class="scopes">
+            <p><strong><?= _('Diese Applikation hat Zugriff auf:') ?></strong></p>
+            <ul>
+                <? foreach ($scopes as $scope) { ?>
+                    <li><?= htmlReady($scope->description) ?></li>
+                <? } ?>
+            </ul>
+        </div>
+    <? } ?>
+
+    <div class="buttons">
+        <form action="<?= $controller->url_for('api/oauth2/authorize') ?>" method="post">
+            <?= \CSRFProtection::tokenTag() ?>
+            <input type="hidden" name="_method" value="delete">
+            <input type="hidden" name="state" value="<?= htmlReady($state) ?>">
+            <input type="hidden" name="client_id" value="<?= htmlReady($client->id) ?>">
+            <input type="hidden" name="auth_token" value="<?= htmlReady($authToken) ?>">
+            <?= Studip\Button::create(_('Verweigern'), 'deny') ?>
+        </form>
+
+        <form action="<?= $controller->url_for('api/oauth2/authorize') ?>" method="post">
+            <?= \CSRFProtection::tokenTag() ?>
+            <input type="hidden" name="state" value="<?= htmlReady($state) ?>">
+            <input type="hidden" name="client_id" value="<?= htmlReady($client->id) ?>">
+            <input type="hidden" name="auth_token" value="<?= htmlReady($authToken) ?>">
+            <?= Studip\Button::create(_('Erlauben'), 'allow') ?>
+        </form>
+    </div>
+
+    <p>
+        <?= Avatar::getAvatar($GLOBALS['user']->id)->getImageTag(Avatar::SMALL) ?>
+
+        <?= sprintf(
+            _('Angemeldet als <strong>%s</strong> (%s)'),
+            htmlReady($GLOBALS['user']->getFullName()),
+            htmlReady($GLOBALS['user']->username)
+            ) ?><br>
+        <small>
+            <a href="<?= URLHelper::getLink('logout.php') ?>">
+                <?= sprintf(
+                    _('Sind sie nicht <strong>%s</strong>, so melden Sie sich bitte ab und versuchen es erneut.'),
+                    htmlReady($GLOBALS['user']->getFullName())
+                ) ?>
+            </a>
+        </small>
+    </p>
+</section>
diff --git a/app/views/api/oauth2/clients/add.php b/app/views/api/oauth2/clients/add.php
new file mode 100644
index 0000000000000000000000000000000000000000..855f446e989762acf929e2174e36b92adc487450
--- /dev/null
+++ b/app/views/api/oauth2/clients/add.php
@@ -0,0 +1,86 @@
+<form class="default" action="<?= $controller->url_for('api/oauth2/clients/store') ?>" method="post">
+    <?= CSRFProtection::tokenTag() ?>
+
+    <fieldset>
+        <legend>
+            <?= _('Basisdaten des OAuth2-Clients') ?>
+        </legend>
+        <label>
+            <span class="required">
+                <?= _('Name') ?>
+            </span>
+            <input required type="text" name="name">
+        </label>
+
+        <label>
+            <span class="required">
+                <?= _('Redirect-URIs') ?>
+            </span>
+            <textarea required name="redirect" placeholder="<?= _('schema://<redirect-uri-1>\nschema://<redirect-uri-2>') ?>" maxlength="1000"></textarea>
+        </label>
+
+        <label>
+            <span>
+                <?= _('Beschreibung') ?>
+            </span>
+            <textarea name="description" maxlength="1000"></textarea>
+        </label>
+    </fieldset>
+
+    <fieldset class="oauth2-clients--confidentiality">
+        <legend class="required">
+            <?= _('Kann der OAuth2-Client kryptographische Geheimnisse bewahren?') ?>
+        </legend>
+
+        <div>
+            <input type="radio" name="confidentiality" value="public" id="oauth2-clients-confidentiality--public" required>
+            <label for="oauth2-clients-confidentiality--public">
+                <?= _('Nein. Es handelt sich zum Beispiel um eine <span lang="en">Mobile App</span> oder <span lang="en">Single Page App</span>.') ?>
+            </label>
+        </div>
+
+        <div>
+            <input type="radio" name="confidentiality" value="confidential" id="oauth2-clients-confidentiality--confidential">
+            <label for="oauth2-clients-confidentiality--confidential">
+                <?= _('Ja, dieser OAuth2-Client kann ein kryptographisches Geheimnis bewahren.') ?>
+            </label>
+        </div>
+    </fieldset>
+
+    <fieldset>
+        <legend>
+            <?= _('Meta-Informationen') ?>
+        </legend>
+
+        <label>
+            <span class="required">
+                <?= _('Von wem wird der OAuth2-Client entwickelt?') ?>
+            </span>
+            <input required type="text" name="owner" maxlength="100">
+        </label>
+
+        <label>
+            <span class="required">
+                <?= _('Homepage der Entwickelnden des OAuth2-Clients') ?>
+            </span>
+            <input required type="url" name="homepage" maxlength="200">
+        </label>
+
+        <label>
+            <span>
+                <?= _('Notizen (nur für Root-Accounts sichtbar)') ?>
+            </span>
+            <textarea name="admin_notes"></textarea>
+        </label>
+
+    </fieldset>
+
+    <footer data-dialog-button>
+        <?= Studip\Button::createAccept(_('Erstellen'), 'create_client', [
+            'title' => _('Neuen OAuth2-Client erstellen'),
+        ]) ?>
+        <?= Studip\LinkButton::createCancel(_('Abbrechen'), $controller->url_for('admin/oauth2'), [
+            'title' => _('Zurück zur Übersicht'),
+        ]) ?>
+    </footer>
+</form>
diff --git a/cli/Commands/OAuth2/Keys.php b/cli/Commands/OAuth2/Keys.php
new file mode 100644
index 0000000000000000000000000000000000000000..c9c073836fca83ef4b3609a78655001b77bbe6f5
--- /dev/null
+++ b/cli/Commands/OAuth2/Keys.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace Studip\Cli\Commands\OAuth2;
+
+use phpseclib\Crypt\RSA;
+use Studip\OAuth2\Container;
+use Studip\OAuth2\KeyInformation;
+use Studip\OAuth2\SetupInformation;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+
+class Keys extends Command
+{
+    protected static $defaultName = 'oauth2:keys';
+
+    protected function configure(): void
+    {
+        $this->setDescription(
+            'Erstelle alle kryptografischen Schlüssel, um Stud.IP als OAuth2-Authorization-Server zu verwenden.'
+        );
+        $this->addOption('force', null, InputOption::VALUE_NONE, 'Überschreibe ggf. vorhandene Schlüssel');
+    }
+
+    protected function execute(InputInterface $input, OutputInterface $output): int
+    {
+        $io = new SymfonyStyle($input, $output);
+
+        $container = new Container();
+        $setup = $container->get(SetupInformation::class);
+
+        $encryptionKey = $setup->encryptionKey();
+        $publicKey = $setup->publicKey();
+        $privateKey = $setup->privateKey();
+
+        $force = $input->getOption('force');
+
+        if (($encryptionKey->exists() || $publicKey->exists() || $privateKey->exists()) && !$force) {
+            $io->error(
+                'Schlüsseldateien liegen bereits vor. Verwenden Sie die Option --force, um diese zu überschreiben.'
+            );
+            return Command::FAILURE;
+        }
+
+        $this->storeKeyContentsToFile($encryptionKey, $this->generateEncryptionKey());
+
+        $keys = (new RSA())->createKey(4096);
+        $this->storeKeyContentsToFile($publicKey, $keys['publickey']);
+        $this->storeKeyContentsToFile($privateKey, $keys['privatekey']);
+
+        $io->info('Schlüsseldateien erfolgreich angelegt.');
+
+        return Command::SUCCESS;
+    }
+
+    private function storeKeyContentsToFile(KeyInformation $key, string $contents)
+    {
+        file_put_contents($key->filename(), $contents);
+        chmod($key->filename(), 0660);
+    }
+
+    private function generateEncryptionKey(): string
+    {
+        return "<?php return '" . randomString(48) . "';";
+    }
+}
diff --git a/cli/Commands/OAuth2/Purge.php b/cli/Commands/OAuth2/Purge.php
new file mode 100644
index 0000000000000000000000000000000000000000..3f7561f79e853dcee7a9ebbd2daaa9ee8d1fc21f
--- /dev/null
+++ b/cli/Commands/OAuth2/Purge.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Studip\Cli\Commands\OAuth2;
+
+use Studip\OAuth2\Models\AccessToken;
+use Studip\OAuth2\Models\AuthCode;
+use Studip\OAuth2\Models\RefreshToken;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+
+class Purge extends Command
+{
+    protected static $defaultName = 'oauth2:purge';
+
+    protected function configure(): void
+    {
+        $this->setDescription('Bereinige die OAuth2-Datenbanktabellen von widerrufenen und/oder abgelaufenen Token');
+        $this->addOption('revoked', null, InputOption::VALUE_NONE, 'Entferne widerrufene Token');
+        $this->addOption('expired', null, InputOption::VALUE_NONE, 'Entferne abgelaufene Token');
+    }
+
+    protected function execute(InputInterface $input, OutputInterface $output): int
+    {
+        $io = new SymfonyStyle($input, $output);
+
+        $expiryDate = strtotime('-7days midnight');
+
+        $revoked = $input->getOption('revoked');
+        $expired = $input->getOption('expired');
+
+        if (($revoked && $expired) || (!$revoked && !$expired)) {
+            AccessToken::deleteBySQL('revoked = ? AND expires_at < ?', [1, $expiryDate]);
+            AuthCode::deleteBySQL('revoked = ? AND expires_at < ?', [1, $expiryDate]);
+            RefreshToken::deleteBySQL('revoked = ? AND expires_at < ?', [1, $expiryDate]);
+
+            $io->info(
+                'Alle Token, die widerrufen wurden oder vor mindestens 7 Tagen abgelaufen sind, wurden entfernt.'
+            );
+        } elseif ($revoked) {
+            AccessToken::deleteBySQL('revoked = ?', [1]);
+            AuthCode::deleteBySQL('revoked = ?', [1]);
+            RefreshToken::deleteBySQL('revoked = ?', [1]);
+
+            $io->info('Alle widerrufenen Token wurden entfernt.');
+        } elseif ($expired) {
+            AccessToken::deleteBySQL('expires_at < ?', [$expiryDate]);
+            AuthCode::deleteBySQL('expires_at < ?', [$expiryDate]);
+            RefreshToken::deleteBySQL('expires_at < ?', [$expiryDate]);
+
+            $io->info('Alle Token, die vor mindestens 7 Tagen abgelaufen sind, wurden entfernt.');
+        }
+
+        return Command::SUCCESS;
+    }
+}
diff --git a/cli/studip b/cli/studip
index af6273f62b89690f2186fafb16dfb671498f5dab..2b242387280b10f7b8af612cbafec594123ab9c1 100755
--- a/cli/studip
+++ b/cli/studip
@@ -37,6 +37,8 @@ $commands = [
     Commands\Migrate\MigrateList::class,
     Commands\Migrate\MigrateStatus::class,
     Commands\Migrate\Migrate::class,
+    Commands\OAuth2\Keys::class,
+    Commands\OAuth2\Purge::class,
     Commands\Plugins\PluginActivate::class,
     Commands\Plugins\PluginDeactivate::class,
     Commands\Plugins\PluginInfo::class,
diff --git a/composer.json b/composer.json
index 21f4c3b0cee9928fb949401bb3e0f49fa189fc4d..696383c3c73b5c47cdec2fd7ff38435527e1c5b2 100644
--- a/composer.json
+++ b/composer.json
@@ -49,8 +49,9 @@
         "slim/psr7": "1.4",
         "slim/slim": "4.7.1",
         "php-di/php-di": "6.3.4",
-        "symfony/console": "5.3.6",
+        "symfony/console": "~5.3.16",
         "symfony/process": "^5.4",
-        "jumbojett/openid-connect-php": "^0.9.2"
+        "jumbojett/openid-connect-php": "^0.9.2",
+        "league/oauth2-server": "^8.3"
     }
 }
diff --git a/composer.lock b/composer.lock
index fed4287ec42fd68729b05053c0a7335a18b39ee8..6ee399c66cbcbbbd85f8530f37399aef101dd6e9 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": "1e33133d5b5338062325db583d6e62a7",
+    "content-hash": "d8b9598df77b1d0451559f6951ad6c2d",
     "packages": [
         {
             "name": "algo26-matthias/idna-convert",
@@ -86,12 +86,12 @@
             },
             "type": "library",
             "autoload": {
-                "psr-4": {
-                    "Assert\\": "lib/Assert"
-                },
                 "files": [
                     "lib/Assert/functions.php"
-                ]
+                ],
+                "psr-4": {
+                    "Assert\\": "lib/Assert"
+                }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
@@ -242,12 +242,12 @@
             },
             "type": "library",
             "autoload": {
-                "psr-0": {
-                    "HTMLPurifier": "library/"
-                },
                 "files": [
                     "library/HTMLPurifier.composer.php"
                 ],
+                "psr-0": {
+                    "HTMLPurifier": "library/"
+                },
                 "exclude-from-classmap": [
                     "/library/HTMLPurifier/Language/"
                 ]
@@ -335,12 +335,12 @@
             "version": "v1.6",
             "source": {
                 "type": "git",
-                "url": "https://github.com/gossi/docblock.git",
+                "url": "https://github.com/phpowermove/docblock.git",
                 "reference": "d7e2f299279f5aebbfddeef1c119e26bef4bc7e9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/gossi/docblock/zipball/d7e2f299279f5aebbfddeef1c119e26bef4bc7e9",
+                "url": "https://api.github.com/repos/phpowermove/docblock/zipball/d7e2f299279f5aebbfddeef1c119e26bef4bc7e9",
                 "reference": "d7e2f299279f5aebbfddeef1c119e26bef4bc7e9",
                 "shasum": ""
             },
@@ -376,8 +376,9 @@
             ],
             "support": {
                 "issues": "https://github.com/gossi/docblock/issues",
-                "source": "https://github.com/gossi/docblock/tree/master"
+                "source": "https://github.com/phpowermove/docblock/tree/v1.6"
             },
+            "abandoned": "phpowermove/docblock",
             "time": "2017-07-01T18:10:54+00:00"
         },
         {
@@ -411,12 +412,12 @@
                 }
             },
             "autoload": {
-                "psr-4": {
-                    "GuzzleHttp\\Psr7\\": "src/"
-                },
                 "files": [
                     "src/functions_include.php"
-                ]
+                ],
+                "psr-4": {
+                    "GuzzleHttp\\Psr7\\": "src/"
+                }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
@@ -780,12 +781,12 @@
             },
             "type": "library",
             "autoload": {
-                "psr-4": {
-                    "Neomerx\\JsonApi\\": "src/"
-                },
                 "files": [
                     "src/I18n/format.php"
-                ]
+                ],
+                "psr-4": {
+                    "Neomerx\\JsonApi\\": "src/"
+                }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
@@ -835,12 +836,12 @@
             },
             "type": "library",
             "autoload": {
-                "psr-4": {
-                    "FastRoute\\": "src/"
-                },
                 "files": [
                     "src/functions.php"
-                ]
+                ],
+                "psr-4": {
+                    "FastRoute\\": "src/"
+                }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
@@ -891,12 +892,12 @@
                 }
             },
             "autoload": {
-                "psr-4": {
-                    "Opis\\Closure\\": "src/"
-                },
                 "files": [
                     "functions.php"
-                ]
+                ],
+                "psr-4": {
+                    "Opis\\Closure\\": "src/"
+                }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
@@ -1302,12 +1303,12 @@
             },
             "type": "library",
             "autoload": {
-                "psr-4": {
-                    "DI\\": "src/"
-                },
                 "files": [
                     "src/functions.php"
-                ]
+                ],
+                "psr-4": {
+                    "DI\\": "src/"
+                }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
@@ -2492,12 +2493,12 @@
                 }
             },
             "autoload": {
-                "psr-4": {
-                    "Symfony\\Polyfill\\Ctype\\": ""
-                },
                 "files": [
                     "bootstrap.php"
-                ]
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Ctype\\": ""
+                }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
@@ -2571,12 +2572,12 @@
                 }
             },
             "autoload": {
-                "psr-4": {
-                    "Symfony\\Polyfill\\Intl\\Grapheme\\": ""
-                },
                 "files": [
                     "bootstrap.php"
-                ]
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Intl\\Grapheme\\": ""
+                }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
@@ -2652,12 +2653,12 @@
                 }
             },
             "autoload": {
-                "psr-4": {
-                    "Symfony\\Polyfill\\Intl\\Normalizer\\": ""
-                },
                 "files": [
                     "bootstrap.php"
                 ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Intl\\Normalizer\\": ""
+                },
                 "classmap": [
                     "Resources/stubs"
                 ]
@@ -2732,12 +2733,12 @@
                 }
             },
             "autoload": {
-                "psr-4": {
-                    "Symfony\\Polyfill\\Mbstring\\": ""
-                },
                 "files": [
                     "bootstrap.php"
-                ]
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Mbstring\\": ""
+                }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
@@ -2796,12 +2797,12 @@
                 }
             },
             "autoload": {
-                "psr-4": {
-                    "Symfony\\Polyfill\\Php56\\": ""
-                },
                 "files": [
                     "bootstrap.php"
-                ]
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Php56\\": ""
+                }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
@@ -2872,12 +2873,12 @@
                 }
             },
             "autoload": {
-                "psr-4": {
-                    "Symfony\\Polyfill\\Php73\\": ""
-                },
                 "files": [
                     "bootstrap.php"
                 ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Php73\\": ""
+                },
                 "classmap": [
                     "Resources/stubs"
                 ]
@@ -2951,12 +2952,12 @@
                 }
             },
             "autoload": {
-                "psr-4": {
-                    "Symfony\\Polyfill\\Php80\\": ""
-                },
                 "files": [
                     "bootstrap.php"
                 ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Php80\\": ""
+                },
                 "classmap": [
                     "Resources/stubs"
                 ]
@@ -3250,12 +3251,12 @@
             },
             "type": "library",
             "autoload": {
-                "psr-4": {
-                    "Symfony\\Component\\String\\": ""
-                },
                 "files": [
                     "Resources/functions.php"
                 ],
+                "psr-4": {
+                    "Symfony\\Component\\String\\": ""
+                },
                 "exclude-from-classmap": [
                     "/Tests/"
                 ]
@@ -3801,6 +3802,7 @@
                 "issues": "https://github.com/camspiers/json-pretty/issues",
                 "source": "https://github.com/camspiers/json-pretty/tree/master"
             },
+            "abandoned": true,
             "time": "2016-02-06T01:25:58+00:00"
         },
         {
@@ -3825,12 +3827,12 @@
             },
             "type": "library",
             "autoload": {
-                "psr-4": {
-                    "Clue\\StreamFilter\\": "src/"
-                },
                 "files": [
                     "src/functions_include.php"
-                ]
+                ],
+                "psr-4": {
+                    "Clue\\StreamFilter\\": "src/"
+                }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
@@ -4325,9 +4327,6 @@
             "require": {
                 "php": "^7.1 || ^8.0"
             },
-            "replace": {
-                "myclabs/deep-copy": "self.version"
-            },
             "require-dev": {
                 "doctrine/collections": "^1.0",
                 "doctrine/common": "^2.6",
@@ -4335,12 +4334,12 @@
             },
             "type": "library",
             "autoload": {
-                "psr-4": {
-                    "DeepCopy\\": "src/DeepCopy/"
-                },
                 "files": [
                     "src/DeepCopy/deep_copy.php"
-                ]
+                ],
+                "psr-4": {
+                    "DeepCopy\\": "src/DeepCopy/"
+                }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
@@ -4827,12 +4826,12 @@
                 }
             },
             "autoload": {
-                "psr-4": {
-                    "Http\\Message\\": "src/"
-                },
                 "files": [
                     "src/filters.php"
-                ]
+                ],
+                "psr-4": {
+                    "Http\\Message\\": "src/"
+                }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
@@ -5192,16 +5191,16 @@
         },
         {
             "name": "phpstan/phpstan",
-            "version": "1.8.1",
+            "version": "1.7.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpstan/phpstan.git",
-                "reference": "8dbba631fa32f4b289404469c2afd6122fd61d67"
+                "reference": "cd0202ea1b1fc6d1bbe156c6e2e18a03e0ff160a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8dbba631fa32f4b289404469c2afd6122fd61d67",
-                "reference": "8dbba631fa32f4b289404469c2afd6122fd61d67",
+                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/cd0202ea1b1fc6d1bbe156c6e2e18a03e0ff160a",
+                "reference": "cd0202ea1b1fc6d1bbe156c6e2e18a03e0ff160a",
                 "shasum": ""
             },
             "require": {
@@ -5227,7 +5226,7 @@
             "description": "PHPStan - PHP Static Analysis Tool",
             "support": {
                 "issues": "https://github.com/phpstan/phpstan/issues",
-                "source": "https://github.com/phpstan/phpstan/tree/1.8.1"
+                "source": "https://github.com/phpstan/phpstan/tree/1.7.15"
             },
             "funding": [
                 {
@@ -5247,7 +5246,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-07-12T16:08:06+00:00"
+            "time": "2022-06-20T08:29:01+00:00"
         },
         {
             "name": "phpunit/php-code-coverage",
@@ -6900,8 +6899,7 @@
         "ext-json": "*",
         "ext-pcre": "*",
         "ext-pdo": "*",
-        "ext-mbstring": "*",
-        "ext-dom": "*"
+        "ext-mbstring": "*"
     },
     "platform-dev": [],
     "plugin-api-version": "2.3.0"
diff --git a/config/oauth2/.gitkeep b/config/oauth2/.gitkeep
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/db/migrations/5.2.15_create_oauth2_tables.php b/db/migrations/5.2.15_create_oauth2_tables.php
new file mode 100644
index 0000000000000000000000000000000000000000..460d4e0638429e05febdbb992b0d4ee408c9ece0
--- /dev/null
+++ b/db/migrations/5.2.15_create_oauth2_tables.php
@@ -0,0 +1,83 @@
+<?php
+
+class CreateOauth2Tables extends Migration
+{
+    public function description()
+    {
+        return 'creates all necessary tables for the OAuth2 plugin';
+    }
+
+    public function up()
+    {
+        $db = DBManager::get();
+
+        $query = "CREATE TABLE IF NOT EXISTS `oauth2_access_tokens` (
+                    `id`         VARCHAR(100) NOT NULL,
+                    `user_id`    CHAR(32) COLLATE `latin1_bin` NULL,
+                    `client_id`  BIGINT UNSIGNED NOT NULL,
+                    `scopes`     TEXT NULL,
+                    `revoked`    TINYINT(1) NOT NULL DEFAULT 0,
+                    `expires_at` INT(11) NULL,
+                    `mkdate`     INT(11) NOT NULL,
+                    `chdate`     INT(11) NOT NULL,
+
+                    PRIMARY KEY (`id`),
+                    KEY `user_id` (`user_id`)
+                  )";
+        $db->exec($query);
+
+        $query = "CREATE TABLE IF NOT EXISTS `oauth2_auth_codes` (
+                    `id`           VARCHAR(100) NOT NULL,
+                    `user_id`      CHAR(32) COLLATE `latin1_bin` NOT NULL,
+                    `client_id`    BIGINT UNSIGNED NOT NULL,
+                    `scopes`       TEXT NULL,
+                    `revoked`      TINYINT(1) NOT NULL DEFAULT 0,
+                    `expires_at`   INT(11) NULL,
+                    `mkdate`       INT(11) NOT NULL,
+                    `chdate`       INT(11) NOT NULL,
+
+                    PRIMARY KEY (`id`),
+                    KEY `user_id` (`user_id`)
+                  )";
+        $db->exec($query);
+
+        $query = "CREATE TABLE IF NOT EXISTS `oauth2_clients` (
+                    `id`            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+                    `name`          VARCHAR(255) NOT NULL,
+                    `secret`        VARCHAR(100) NULL,
+                    `redirect`      TEXT NOT NULL,
+                    `revoked`       TINYINT(1) NOT NULL DEFAULT 0,
+
+                    `description`   TEXT NULL,
+                    `owner`         VARCHAR(255) NULL,
+                    `homepage`      VARCHAR(255) NULL,
+                    `admin_notes`   TEXT NULL,
+
+                    `mkdate`        INT(11) NOT NULL,
+                    `chdate`        INT(11) NOT NULL,
+
+                    PRIMARY KEY (`id`)
+                  )";
+        $db->exec($query);
+
+        $query = "CREATE TABLE IF NOT EXISTS `oauth2_refresh_tokens` (
+                    `id`              VARCHAR(100) NOT NULL,
+                    `access_token_id` VARCHAR(100) NOT NULL,
+                    `revoked`         TINYINT(1) NOT NULL DEFAULT 0,
+                    `expires_at`      INT(11) NULL,
+
+                    PRIMARY KEY (`id`),
+                    KEY `access_token_id` (`access_token_id`)
+                  )";
+        $db->exec($query);
+    }
+
+    public function down()
+    {
+        $db = \DBManager::get();
+        $db->exec('DROP TABLE IF EXISTS `oauth2_access_tokens`');
+        $db->exec('DROP TABLE IF EXISTS `oauth2_auth_codes`');
+        $db->exec('DROP TABLE IF EXISTS `oauth2_clients`');
+        $db->exec('DROP TABLE IF EXISTS `oauth2_refresh_tokens`');
+    }
+}
diff --git a/lib/classes/JsonApi/Middlewares/Auth/OAuth2Strategy.php b/lib/classes/JsonApi/Middlewares/Auth/OAuth2Strategy.php
new file mode 100644
index 0000000000000000000000000000000000000000..ce44485a10efd2bcd406b7d52313fb79b61b96c2
--- /dev/null
+++ b/lib/classes/JsonApi/Middlewares/Auth/OAuth2Strategy.php
@@ -0,0 +1,103 @@
+<?php
+
+namespace JsonApi\Middlewares\Auth;
+
+use League\OAuth2\Server\Exception\OAuthServerException;
+use League\OAuth2\Server\ResourceServer;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Studip\OAuth2\Container;
+use Studip\OAuth2\Models\AccessToken;
+use Studip\OAuth2\Models\Client;
+
+class OAuth2Strategy implements Strategy
+{
+    /** @var callable */
+    protected $authenticator;
+
+    /** @var Request */
+    protected $request;
+
+    /** @var ?\User */
+    protected $user;
+
+    /**
+     * @param callable $authenticator
+     */
+    public function __construct(Request $request, $authenticator)
+    {
+        $this->request = $request;
+        $this->authenticator = $authenticator;
+    }
+
+    public function check()
+    {
+        return !is_null($this->user());
+    }
+
+    public function user()
+    {
+        if (!is_null($this->user)) {
+            return $this->user;
+        }
+
+        $this->user = $this->detect();
+
+        return $this->user;
+    }
+
+    public function addChallenge(Response $response)
+    {
+        return $response->withHeader('Authorization', '');
+    }
+
+    private function detect(): ?\User
+    {
+        $bearerToken = $this->bearerToken($this->request);
+        if (!$bearerToken) {
+            return null;
+        }
+
+        $container = new Container();
+        $server = $container->get(ResourceServer::class);
+
+        try {
+            $psrRequest = $server->validateAuthenticatedRequest($this->request);
+
+            $userId = $psrRequest->getAttribute('oauth_user_id');
+            $user = \User::find($userId);
+            if (!$user) {
+                return null;
+            }
+
+            $clientId = $psrRequest->getAttribute('oauth_client_id');
+            if (Client::revoked($clientId)) {
+                return null;
+            }
+
+            return $user;
+        } catch (OAuthServerException $oauthException) {
+            // TODO: reporting?
+        }
+
+        return null;
+    }
+
+    /**
+     * @return string|null
+     */
+    private function bearerToken(Request $request)
+    {
+        if ($request->hasHeader('Authorization')) {
+            $header = $request->getHeaderLine('Authorization');
+            $position = strrpos($header, 'Bearer ');
+            if ($position !== false) {
+                $header = substr($header, $position + 7);
+
+                return strpos($header, ',') !== false ? strstr($header, ',', true) : $header;
+            }
+        }
+
+        return null;
+    }
+}
diff --git a/lib/classes/JsonApi/Middlewares/Authentication.php b/lib/classes/JsonApi/Middlewares/Authentication.php
index 3e4ee176a78a4998c1f5cb18f71788f8676ecc37..5b097d677a33086ebbba4af5a1ce1e16fd1092c9 100644
--- a/lib/classes/JsonApi/Middlewares/Authentication.php
+++ b/lib/classes/JsonApi/Middlewares/Authentication.php
@@ -48,6 +48,7 @@ class Authentication
         $guards = [
             new Auth\SessionStrategy(),
             new Auth\HttpBasicAuthStrategy($request, $this->authenticator),
+            new Auth\OAuth2Strategy($request, $this->authenticator),
             new Auth\OAuth1Strategy($request, $this->authenticator),
         ];
 
diff --git a/lib/classes/OAuth2/Bridge/AccessTokenEntity.php b/lib/classes/OAuth2/Bridge/AccessTokenEntity.php
new file mode 100644
index 0000000000000000000000000000000000000000..987f0a0abe3c07ab9c0299bffa3a604eeaa93af2
--- /dev/null
+++ b/lib/classes/OAuth2/Bridge/AccessTokenEntity.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Studip\OAuth2\Bridge;
+
+use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
+use League\OAuth2\Server\Entities\ClientEntityInterface;
+use League\OAuth2\Server\Entities\Traits\AccessTokenTrait;
+use League\OAuth2\Server\Entities\Traits\EntityTrait;
+use League\OAuth2\Server\Entities\Traits\TokenEntityTrait;
+
+class AccessTokenEntity implements AccessTokenEntityInterface
+{
+    use AccessTokenTrait;
+    use EntityTrait;
+    use TokenEntityTrait;
+
+    /**
+     * Create a new token instance.
+     *
+     * @param string $userIdentifier
+     *
+     * @return void
+     */
+    public function __construct($userIdentifier, array $scopes, ClientEntityInterface $client)
+    {
+        $this->setUserIdentifier($userIdentifier);
+
+        foreach ($scopes as $scope) {
+            $this->addScope($scope);
+        }
+
+        $this->setClient($client);
+    }
+}
diff --git a/lib/classes/OAuth2/Bridge/AccessTokenRepository.php b/lib/classes/OAuth2/Bridge/AccessTokenRepository.php
new file mode 100644
index 0000000000000000000000000000000000000000..2762f6bbc49ca7b9ec975aa4054cd0b197a2c906
--- /dev/null
+++ b/lib/classes/OAuth2/Bridge/AccessTokenRepository.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace Studip\OAuth2\Bridge;
+
+use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
+use League\OAuth2\Server\Entities\ClientEntityInterface;
+use League\OAuth2\Server\Entities\ScopeEntityInterface;
+use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException;
+use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface;
+use Studip\OAuth2\Models\AccessToken;
+
+class AccessTokenRepository implements AccessTokenRepositoryInterface
+{
+    use ScopesHelper;
+
+    /**
+     * Create a new access token.
+     *
+     * @param ScopeEntityInterface[] $scopes
+     * @param mixed                  $userIdentifier
+     *
+     * @return AccessTokenEntityInterface
+     */
+    public function getNewToken(ClientEntityInterface $clientEntity, array $scopes, $userIdentifier = null)
+    {
+        return new AccessTokenEntity($userIdentifier, $scopes, $clientEntity);
+    }
+
+    /**
+     * Persists a new access token to permanent storage.
+     *
+     * @throws UniqueTokenIdentifierConstraintViolationException
+     */
+    public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity): void
+    {
+        AccessToken::create([
+            'id'         => $accessTokenEntity->getIdentifier(),
+            'user_id'    => $accessTokenEntity->getUserIdentifier(),
+            'client_id'  => $accessTokenEntity->getClient()->getIdentifier(),
+            'scopes'     => $this->formatScopes($accessTokenEntity->getScopes()),
+            'revoked'    => 0,
+            'expires_at' => $accessTokenEntity->getExpiryDateTime()->getTimestamp(),
+        ]);
+
+        // TODO: Logging and metrics
+    }
+
+    /**
+     * Revoke an access token.
+     *
+     * @param string $tokenId
+     */
+    public function revokeAccessToken($tokenId): void
+    {
+        $accesstoken = AccessToken::find($tokenId);
+        if ($accesstoken) {
+            $accesstoken->revoke();
+        }
+    }
+
+    /**
+     * Check if the access token has been revoked.
+     *
+     * @param string $tokenId
+     *
+     * @return bool Return true if this token has been revoked
+     */
+    public function isAccessTokenRevoked($tokenId): bool
+    {
+        $accesstoken = AccessToken::find($tokenId);
+
+        return $accesstoken ? $accesstoken->isRevoked() : true;
+    }
+}
diff --git a/lib/classes/OAuth2/Bridge/AuthCodeEntity.php b/lib/classes/OAuth2/Bridge/AuthCodeEntity.php
new file mode 100644
index 0000000000000000000000000000000000000000..5514968aa33ed34714917ac94440f64cbb14b0f7
--- /dev/null
+++ b/lib/classes/OAuth2/Bridge/AuthCodeEntity.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace Studip\OAuth2\Bridge;
+
+use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
+use League\OAuth2\Server\Entities\Traits\AuthCodeTrait;
+use League\OAuth2\Server\Entities\Traits\EntityTrait;
+use League\OAuth2\Server\Entities\Traits\TokenEntityTrait;
+
+class AuthCodeEntity implements AuthCodeEntityInterface
+{
+    use EntityTrait;
+    use TokenEntityTrait;
+    use AuthCodeTrait;
+}
diff --git a/lib/classes/OAuth2/Bridge/AuthCodeRepository.php b/lib/classes/OAuth2/Bridge/AuthCodeRepository.php
new file mode 100644
index 0000000000000000000000000000000000000000..5676622269e9fc60bd5555d2b780d41d0d74646d
--- /dev/null
+++ b/lib/classes/OAuth2/Bridge/AuthCodeRepository.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Studip\OAuth2\Bridge;
+
+use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
+use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException;
+use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface;
+use Studip\OAuth2\Models\AuthCode;
+
+class AuthCodeRepository implements AuthCodeRepositoryInterface
+{
+    use ScopesHelper;
+
+    /**
+     * Creates a new AuthCode.
+     */
+    public function getNewAuthCode(): AuthCodeEntityInterface
+    {
+        return new AuthCodeEntity();
+    }
+
+    /**
+     * Persists a new auth code to permanent storage.
+     *
+     * @return void
+     *
+     * @throws UniqueTokenIdentifierConstraintViolationException
+     */
+    public function persistNewAuthCode(AuthCodeEntityInterface $authCodeEntity)
+    {
+        AuthCode::create([
+            'id'         => $authCodeEntity->getIdentifier(),
+            'user_id'    => $authCodeEntity->getUserIdentifier(),
+            'client_id'  => $authCodeEntity->getClient()->getIdentifier(),
+            'scopes'     => $this->formatScopes($authCodeEntity->getScopes()),
+            'revoked'    => 0,
+            'expires_at' => $authCodeEntity->getExpiryDateTime()->getTimestamp(),
+        ]);
+
+        // TODO: Logging and metrics
+    }
+
+    /**
+     * Revoke an auth code.
+     *
+     * @param string $codeId
+     */
+    public function revokeAuthCode($codeId): void
+    {
+        $authCode = AuthCode::find($codeId);
+        if ($authCode) {
+            $authCode->revoke();
+        }
+    }
+
+    /**
+     * Check if the auth code has been revoked.
+     *
+     * @param string $codeId
+     *
+     * @return bool Return true if this code has been revoked
+     */
+    public function isAuthCodeRevoked($codeId): bool
+    {
+        $authCode = AuthCode::find($codeId);
+
+        return $authCode ? $authCode->isRevoked() : true;
+    }
+}
diff --git a/lib/classes/OAuth2/Bridge/ClientEntity.php b/lib/classes/OAuth2/Bridge/ClientEntity.php
new file mode 100644
index 0000000000000000000000000000000000000000..106caa03733a36cfb4e77b130e96e1bb6464c835
--- /dev/null
+++ b/lib/classes/OAuth2/Bridge/ClientEntity.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Studip\OAuth2\Bridge;
+
+use League\OAuth2\Server\Entities\ClientEntityInterface;
+use League\OAuth2\Server\Entities\Traits\ClientTrait;
+use League\OAuth2\Server\Entities\Traits\EntityTrait;
+
+class ClientEntity implements ClientEntityInterface
+{
+    use ClientTrait;
+    use EntityTrait;
+
+    /**
+     * @param string          $identifier
+     * @param string          $name
+     * @param string|string[] $redirectUri
+     * @param bool $isConfidential
+     */
+    public function __construct($identifier, $name, $redirectUri, $isConfidential)
+    {
+        $this->identifier = $identifier;
+        $this->name = $name;
+        $this->redirectUri = $redirectUri;
+        $this->isConfidential = $isConfidential;
+    }
+}
diff --git a/lib/classes/OAuth2/Bridge/ClientRepository.php b/lib/classes/OAuth2/Bridge/ClientRepository.php
new file mode 100644
index 0000000000000000000000000000000000000000..b6fd4f6a85624ca00f36ccd6584a78f0885e0f59
--- /dev/null
+++ b/lib/classes/OAuth2/Bridge/ClientRepository.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace Studip\OAuth2\Bridge;
+
+use League\OAuth2\Server\Entities\ClientEntityInterface;
+use League\OAuth2\Server\Repositories\ClientRepositoryInterface;
+use Studip\OAuth2\Models\Client;
+
+class ClientRepository implements ClientRepositoryInterface
+{
+    /**
+     * Get a client.
+     *
+     * @param string $clientIdentifier The client's identifier
+     */
+    public function getClientEntity($clientIdentifier): ?ClientEntityInterface
+    {
+        $sorm = Client::findActive($clientIdentifier);
+        if (!$sorm) {
+            return null;
+        }
+
+        return new ClientEntity(
+            $clientIdentifier,
+            $sorm['name'],
+            explode(',', $sorm['redirect']),
+            $sorm->confidential()
+        );
+    }
+
+    /**
+     * Validate a client's secret.
+     *
+     * @param string      $clientIdentifier The client's identifier
+     * @param string|null $clientSecret     The client's secret (if sent)
+     * @param string|null $grantType        The type of grant the client is using (if sent)
+     */
+    public function validateClient($clientIdentifier, $clientSecret, $grantType): bool
+    {
+        if ($grantType !== 'authorization_code') {
+            return false;
+        }
+        $client = Client::findActive($clientIdentifier);
+        if (!$client) {
+            return false;
+        }
+
+        return !$client->confidential() || $this->verifySecret((string) $clientSecret, $client->secret);
+    }
+
+    /**
+     * @param string $clientSecret
+     * @param string $storedHash
+     */
+    protected function verifySecret($clientSecret, $storedHash): bool
+    {
+        return password_verify($clientSecret, $storedHash);
+    }
+}
diff --git a/lib/classes/OAuth2/Bridge/RefreshTokenEntity.php b/lib/classes/OAuth2/Bridge/RefreshTokenEntity.php
new file mode 100644
index 0000000000000000000000000000000000000000..a0dda5e3f4a928087152c72b1da3af8cd3aa1910
--- /dev/null
+++ b/lib/classes/OAuth2/Bridge/RefreshTokenEntity.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace Studip\OAuth2\Bridge;
+
+use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
+use League\OAuth2\Server\Entities\Traits\EntityTrait;
+use League\OAuth2\Server\Entities\Traits\RefreshTokenTrait;
+
+class RefreshTokenEntity implements RefreshTokenEntityInterface
+{
+    use RefreshTokenTrait;
+    use EntityTrait;
+}
diff --git a/lib/classes/OAuth2/Bridge/RefreshTokenRepository.php b/lib/classes/OAuth2/Bridge/RefreshTokenRepository.php
new file mode 100644
index 0000000000000000000000000000000000000000..44cb16c6578efbc5c616940428962727191da52e
--- /dev/null
+++ b/lib/classes/OAuth2/Bridge/RefreshTokenRepository.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace Studip\OAuth2\Bridge;
+
+use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
+use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException;
+use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
+use Studip\OAuth2\Models\RefreshToken;
+
+class RefreshTokenRepository implements RefreshTokenRepositoryInterface
+{
+    /**
+     * Creates a new refresh token.
+     */
+    public function getNewRefreshToken(): RefreshTokenEntityInterface
+    {
+        return new RefreshTokenEntity();
+    }
+
+    /**
+     * Create a new refresh token_name.
+     *
+     * @throws UniqueTokenIdentifierConstraintViolationException
+     */
+    public function persistNewRefreshToken(RefreshTokenEntityInterface $refreshTokenEntity): void
+    {
+        RefreshToken::create([
+                'id'              => $refreshTokenEntity->getIdentifier(),
+                'access_token_id' => $refreshTokenEntity->getAccessToken()->getIdentifier(),
+                'revoked'         => 0,
+                'expires_at'      => $refreshTokenEntity->getExpiryDateTime()->getTimestamp(),
+        ]);
+
+        // TODO: Logging and metrics
+    }
+
+    /**
+     * Revoke the refresh token.
+     *
+     * @param string $tokenId
+     */
+    public function revokeRefreshToken($tokenId): void
+    {
+        $refreshToken = RefreshToken::find($tokenId);
+        if ($refreshToken) {
+            $refreshToken->revoke();
+        }
+    }
+
+    /**
+     * Check if the refresh token has been revoked.
+     *
+     * @param string $tokenId
+     *
+     * @return bool Return true if this token has been revoked
+     */
+    public function isRefreshTokenRevoked($tokenId): bool
+    {
+        $refreshToken = RefreshToken::find($tokenId);
+
+        return $refreshToken ? $refreshToken->isRevoked() : true;
+    }
+}
diff --git a/lib/classes/OAuth2/Bridge/ScopeEntity.php b/lib/classes/OAuth2/Bridge/ScopeEntity.php
new file mode 100644
index 0000000000000000000000000000000000000000..844600a7a256fe7fe1c40884723aaf2dbdf8260a
--- /dev/null
+++ b/lib/classes/OAuth2/Bridge/ScopeEntity.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace Studip\OAuth2\Bridge;
+
+use League\OAuth2\Server\Entities\ScopeEntityInterface;
+use League\OAuth2\Server\Entities\Traits\EntityTrait;
+use League\OAuth2\Server\Entities\Traits\ScopeTrait;
+
+class ScopeEntity implements ScopeEntityInterface
+{
+    use ScopeTrait;
+    use EntityTrait;
+
+    public function __construct(string $identifier)
+    {
+        $this->setIdentifier($identifier);
+    }
+}
diff --git a/lib/classes/OAuth2/Bridge/ScopeRepository.php b/lib/classes/OAuth2/Bridge/ScopeRepository.php
new file mode 100644
index 0000000000000000000000000000000000000000..65d666e8486d916d555a8a188ab6d60273c20fb8
--- /dev/null
+++ b/lib/classes/OAuth2/Bridge/ScopeRepository.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace Studip\OAuth2\Bridge;
+
+use League\OAuth2\Server\Entities\ClientEntityInterface;
+use League\OAuth2\Server\Entities\ScopeEntityInterface;
+use League\OAuth2\Server\Repositories\ScopeRepositoryInterface;
+use Psr\Container\ContainerInterface;
+use Studip\OAuth2\Models\Scope;
+
+class ScopeRepository implements ScopeRepositoryInterface
+{
+    /** @var array<string, string> */
+    private $scopes;
+
+    public function __construct(ContainerInterface $container)
+    {
+        $this->scopes = Scope::scopes();
+    }
+
+    /**
+     * Return information about a scope.
+     *
+     * @param string $identifier The scope identifier
+     */
+    public function getScopeEntityByIdentifier($identifier): ?ScopeEntityInterface
+    {
+        if (!isset($this->scopes[$identifier])) {
+            return null;
+        }
+
+        return new ScopeEntity($identifier);
+    }
+
+    /**
+     * Given a client, grant type and optional user identifier validate
+     * the set of scopes requested are valid and
+     * optionally append additional scopes or remove requested scopes.
+     *
+     * @param ScopeEntityInterface[] $scopes
+     * @param string                 $grantType
+     * @param ClientEntityInterface  $clientEntity
+     * @param null|string            $userIdentifier
+     *
+     * @return ScopeEntityInterface[]
+     */
+    public function finalizeScopes(
+        array $scopes,
+        $grantType,
+        ClientEntityInterface $clientEntity,
+        $userIdentifier = null
+    ) {
+        return array_filter(
+            $scopes,
+            function ($scope) {
+                return isset($this->scopes[$scope->getIdentifier()]);
+            }
+        );
+    }
+}
diff --git a/lib/classes/OAuth2/Bridge/ScopesHelper.php b/lib/classes/OAuth2/Bridge/ScopesHelper.php
new file mode 100644
index 0000000000000000000000000000000000000000..d075381ef23f24b9f03d82f9e6d53bc41cb47439
--- /dev/null
+++ b/lib/classes/OAuth2/Bridge/ScopesHelper.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace Studip\OAuth2\Bridge;
+
+trait ScopesHelper
+{
+    public function formatScopes(array $scopes): string
+    {
+        return json_encode($this->scopesToArray($scopes));
+    }
+
+    public function scopesToArray(array $scopes): array
+    {
+        return array_map(function ($scope) {
+            return $scope->getIdentifier();
+        }, $scopes);
+    }
+}
diff --git a/lib/classes/OAuth2/Bridge/UserEntity.php b/lib/classes/OAuth2/Bridge/UserEntity.php
new file mode 100644
index 0000000000000000000000000000000000000000..02ba52ffaf07bf4379f35b36c12e5c044ff0a85c
--- /dev/null
+++ b/lib/classes/OAuth2/Bridge/UserEntity.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace Studip\OAuth2\Bridge;
+
+use League\OAuth2\Server\Entities\Traits\EntityTrait;
+use League\OAuth2\Server\Entities\UserEntityInterface;
+
+class UserEntity implements UserEntityInterface
+{
+    use EntityTrait;
+
+    public function __construct(string $identifier)
+    {
+        $this->setIdentifier($identifier);
+    }
+}
diff --git a/lib/classes/OAuth2/Container.php b/lib/classes/OAuth2/Container.php
new file mode 100644
index 0000000000000000000000000000000000000000..e46b127eea27eafc3453bf25d15a6e9c6764560e
--- /dev/null
+++ b/lib/classes/OAuth2/Container.php
@@ -0,0 +1,121 @@
+<?php
+
+namespace Studip\OAuth2;
+
+use DateInterval;
+use DI\ContainerBuilder;
+use League\OAuth2\Server\AuthorizationServer;
+use League\OAuth2\Server\Grant\AuthCodeGrant;
+use League\OAuth2\Server\Grant\RefreshTokenGrant;
+use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface;
+use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface;
+use League\OAuth2\Server\Repositories\ClientRepositoryInterface;
+use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
+use League\OAuth2\Server\Repositories\ScopeRepositoryInterface;
+use League\OAuth2\Server\ResourceServer;
+use Psr\Container\ContainerInterface;
+use Studip\OAuth2\Exceptions\SetupError;
+
+class Container
+{
+    /** @var ContainerInterface */
+    private $container;
+
+    /**
+     * @return mixed
+     */
+    public function get(string $key)
+    {
+        return $this->container->get($key);
+    }
+
+    public function __construct()
+    {
+        $containerBuilder = new ContainerBuilder();
+        $this->addConfiguration($containerBuilder);
+        $this->addDependencies($containerBuilder);
+        $this->container = $containerBuilder->build();
+    }
+
+    private function addConfiguration(ContainerBuilder $containerBuilder): void
+    {
+        $basePath = $GLOBALS['STUDIP_BASE_PATH'];
+        $containerBuilder->addDefinitions([
+            'encryption_key' => $basePath . '/config/oauth2/encryption_key.php',
+            'private_key' => $basePath . '/config/oauth2/private.key',
+            'public_key' => $basePath . '/config/oauth2/public.key',
+
+            // TODO: use these and more of them
+            'tokens_expire_in' => 'P1Y',
+            'refresh_tokens_expire_in' => 'P1Y',
+        ]);
+    }
+
+    private function addDependencies(ContainerBuilder $containerBuilder): void
+    {
+        $containerBuilder->addDefinitions([
+            AccessTokenRepositoryInterface::class => \DI\get(Bridge\AccessTokenRepository::class),
+            AuthCodeRepositoryInterface::class => \DI\get(Bridge\AuthCodeRepository::class),
+            ClientRepositoryInterface::class => \DI\get(Bridge\ClientRepository::class),
+            RefreshTokenRepositoryInterface::class => \DI\get(Bridge\RefreshTokenRepository::class),
+            ScopeRepositoryInterface::class => \DI\get(Bridge\ScopeRepository::class),
+
+            AuthorizationServer::class => function (
+                ContainerInterface $container,
+                AccessTokenRepositoryInterface $accessTokenRepository,
+                ClientRepositoryInterface $clientRepository,
+                ScopeRepositoryInterface $scopeRepository,
+                AuthCodeGrant $authCodeGrant,
+                RefreshTokenGrant $refreshGrant
+            ) {
+                $encryptionKeyFile = $container->get('encryption_key');
+                $privateKey = $container->get('private_key');
+                if (!is_readable($encryptionKeyFile) || !is_readable($privateKey)) {
+                    throw new SetupError();
+                }
+
+                $encryptionKey = include $encryptionKeyFile;
+
+                $server = new AuthorizationServer(
+                    $clientRepository,
+                    $accessTokenRepository,
+                    $scopeRepository,
+                    $privateKey,
+                    $encryptionKey
+                );
+
+                $server->enableGrantType($authCodeGrant, new DateInterval('PT1H'));
+                $server->enableGrantType($refreshGrant, new DateInterval('PT1H'));
+
+                return $server;
+            },
+
+            AuthCodeGrant::class => function (
+                AuthCodeRepositoryInterface $authCodeRepository,
+                RefreshTokenRepositoryInterface $refreshTokenRepository
+            ) {
+                $grant = new AuthCodeGrant($authCodeRepository, $refreshTokenRepository, new DateInterval('PT10M'));
+                $grant->setRefreshTokenTTL(new DateInterval('P1M'));
+
+                return $grant;
+            },
+
+            RefreshTokenGrant::class => function (RefreshTokenRepositoryInterface $refreshTokenRepository) {
+                $refreshGrant = new RefreshTokenGrant($refreshTokenRepository);
+                $refreshGrant->setRefreshTokenTTL(new DateInterval('P1M'));
+
+                return $refreshGrant;
+            },
+
+            ResourceServer::class => function (
+                ContainerInterface $container,
+                AccessTokenRepositoryInterface $accessTokenRepository
+            ) {
+                $publicKey = $container->get('public_key');
+                $resourceServer = new ResourceServer($accessTokenRepository, $publicKey);
+
+                return $resourceServer;
+            },
+        ]);
+    }
+}
diff --git a/lib/classes/OAuth2/Exceptions/InvalidAuthTokenException.php b/lib/classes/OAuth2/Exceptions/InvalidAuthTokenException.php
new file mode 100644
index 0000000000000000000000000000000000000000..69949b150b80044bd895ebf12950697f42005d0c
--- /dev/null
+++ b/lib/classes/OAuth2/Exceptions/InvalidAuthTokenException.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace Studip\OAuth2\Exceptions;
+
+class InvalidAuthTokenException extends \AccessDeniedException
+{
+    /**
+     * Create a new InvalidAuthTokenException for different auth tokens.
+     *
+     * @return static
+     */
+    public static function different()
+    {
+        return new static('The provided auth token for the request is different from the session auth token.');
+    }
+}
diff --git a/lib/classes/OAuth2/Exceptions/SetupError.php b/lib/classes/OAuth2/Exceptions/SetupError.php
new file mode 100644
index 0000000000000000000000000000000000000000..e7b9928021b2da49cab59f9144c8e23142ceb55f
--- /dev/null
+++ b/lib/classes/OAuth2/Exceptions/SetupError.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace Studip\OAuth2\Exceptions;
+
+use League\OAuth2\Server\Exception\OAuthServerException;
+
+class SetupError extends OAuthServerException
+{
+    public function __construct()
+    {
+        $message = _('Das OAuth2-Setup dieser Stud.IP-Installation ist fehlerhaft.');
+
+        parent::__construct($message, 500, 'invalid_setup', 500);
+    }
+}
diff --git a/lib/classes/OAuth2/KeyInformation.php b/lib/classes/OAuth2/KeyInformation.php
new file mode 100644
index 0000000000000000000000000000000000000000..8fe7a4f150b104ba28b5de0665598bb44251aeae
--- /dev/null
+++ b/lib/classes/OAuth2/KeyInformation.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Studip\OAuth2;
+
+class KeyInformation
+{
+    /** @var string */
+    private $filename;
+
+    public function __construct(string $filename)
+    {
+        $this->filename = $filename;
+    }
+
+    public function filename(): string
+    {
+        return $this->filename;
+    }
+
+    public function exists(): bool
+    {
+        return file_exists($this->filename);
+    }
+
+    public function isReadable(): bool
+    {
+        return is_readable($this->filename);
+    }
+
+    public function hasProperMode(): bool
+    {
+        return $this->mode() === '600' ||  $this->mode() === '660';
+    }
+
+    public function mode(): string
+    {
+        $result = '';
+        if ($this->isReadable()) {
+            $stat = stat($this->filename);
+            if ($stat !== false) {
+                $result = substr(sprintf('%o', $stat['mode']), -3);
+            }
+        }
+
+        return $result;
+    }
+}
diff --git a/lib/classes/OAuth2/Models/AccessToken.php b/lib/classes/OAuth2/Models/AccessToken.php
new file mode 100644
index 0000000000000000000000000000000000000000..3c57973d86b898693ddb582a291f801977f43c2c
--- /dev/null
+++ b/lib/classes/OAuth2/Models/AccessToken.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Studip\OAuth2\Models;
+
+/**
+ * @property int $id
+ * @property string $user_id
+ * @property string $client_id
+ * @property string $scopes
+ * @property bool $revoked
+ * @property int $expires_at
+ * @property int $mkdate
+ * @property int $chdate
+ */
+class AccessToken extends \SimpleORMap
+{
+    use RevokedHelper;
+
+    protected static function configure($config = [])
+    {
+        $config['db_table'] = 'oauth2_access_tokens';
+
+        $config['belongs_to']['client'] = [
+            'class_name'  => Client::class,
+            'foreign_key' => 'client_id',
+        ];
+
+        $config['belongs_to']['user'] = [
+            'class_name'  => \User::class,
+            'foreign_key' => 'user_id',
+        ];
+
+        $config['has_many']['refresh_tokens'] = [
+            'class_name'        => RefreshToken::class,
+            'assoc_foreign_key' => 'access_token_id',
+            'on_delete'         => 'delete',
+            'on_store'          => 'store',
+        ];
+
+        parent::configure($config);
+    }
+
+    public static function findValidTokens(\User $user)
+    {
+        return static::findBySQL(
+            'user_id = ? AND revoked = ? AND expires_at > ?',
+            [$user->id, 0, time()]
+        );
+    }
+}
diff --git a/lib/classes/OAuth2/Models/AuthCode.php b/lib/classes/OAuth2/Models/AuthCode.php
new file mode 100644
index 0000000000000000000000000000000000000000..1a43c8c9a28fcc565d5172bd6bee45cbdefcbb74
--- /dev/null
+++ b/lib/classes/OAuth2/Models/AuthCode.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Studip\OAuth2\Models;
+
+/**
+ * @property int $id
+ * @property string $user_id
+ * @property string $client_id
+ * @property string $scopes
+ * @property bool $revoked
+ * @property int $expires_at
+ * @property int $mkdate
+ * @property int $chdate
+ */
+class AuthCode extends \SimpleORMap
+{
+    use RevokedHelper;
+
+    protected static function configure($config = [])
+    {
+        $config['db_table'] = 'oauth2_auth_codes';
+
+        $config['belongs_to']['client'] = [
+            'class_name'  => Client::class,
+            'foreign_key' => 'client_id',
+        ];
+
+        $config['belongs_to']['user'] = [
+            'class_name'  => \User::class,
+            'foreign_key' => 'user_id',
+        ];
+
+        parent::configure($config);
+    }
+}
diff --git a/lib/classes/OAuth2/Models/Client.php b/lib/classes/OAuth2/Models/Client.php
new file mode 100644
index 0000000000000000000000000000000000000000..935e81246c27328ad7da71359420a855e741d3bf
--- /dev/null
+++ b/lib/classes/OAuth2/Models/Client.php
@@ -0,0 +1,122 @@
+<?php
+
+namespace Studip\OAuth2\Models;
+
+/**
+ * @property int $id
+ * @property string $name
+ * @property string|null $secret
+ * @property string $redirect
+ * @property bool $revoked
+ * @property int $mkdate
+ * @property int $chdate
+ */
+class Client extends \SimpleORMap
+{
+    use RevokedHelper;
+
+    /** @var string $plainsecret This is only filled when creating a new Client via `Client::createClient`. */
+    public $plainsecret;
+
+    protected static function configure($config = [])
+    {
+        $config['db_table'] = 'oauth2_clients';
+
+        $config['belongs_to']['user'] = [
+            'class_name'  => \User::class,
+            'foreign_key' => 'user_id',
+        ];
+
+        $config['has_many']['auth_codes'] = [
+            'class_name'        => AuthCode::class,
+            'assoc_foreign_key' => 'client_id',
+            'on_delete'         => 'delete',
+            'on_store'          => 'store',
+            'order_by'          => 'ORDER BY chdate',
+        ];
+
+        $config['has_many']['access_tokens'] = [
+            'class_name'        => AccessToken::class,
+            'assoc_foreign_key' => 'client_id',
+            'on_delete'         => 'delete',
+            'on_store'          => 'store',
+            'order_by'          => 'ORDER BY chdate',
+        ];
+
+        parent::configure($config);
+    }
+
+    /**
+     * Store a new client.
+     *
+     * @return static
+     */
+    public static function createClient(
+        string $name,
+        string $redirect,
+        bool $confidential,
+        string $owner,
+        string $homepage,
+        ?string $description,
+        ?string $adminNotes
+    ) {
+        $secret = null;
+        $plainsecret = null;
+        if ($confidential) {
+            $plainsecret = randomString(40);
+            $secret = password_hash($plainsecret, PASSWORD_BCRYPT);
+        }
+
+        $client = self::create([
+            'name' => $name,
+            'secret' => $secret,
+            'redirect' => $redirect,
+            'revoked' => 0,
+            'owner' => $owner,
+            'homepage' => $homepage,
+            'description' => $description,
+            'admin_notes' => $adminNotes,
+        ]);
+        $client->plainsecret = $plainsecret;
+
+        return $client;
+    }
+
+    /**
+     * @param int|string $clientId
+     *
+     * @return ?static
+     */
+    public static function findActive($clientId)
+    {
+        $client = self::find($clientId);
+
+        return $client && !$client->isRevoked() ? $client : null;
+    }
+
+    /**
+     * @param string $clientId
+     *
+     * @return bool
+     */
+    public static function revoked($clientId): bool
+    {
+        return static::findActive($clientId) === null;
+    }
+
+    /**
+     * @return bool
+     */
+    public function confidential(): bool
+    {
+        return !empty($this->secret);
+    }
+
+    /**
+     * @return string[]
+     */
+    public function redirectURIs(): array
+    {
+        return explode(',', $this->redirect);
+    }
+}
diff --git a/lib/classes/OAuth2/Models/RefreshToken.php b/lib/classes/OAuth2/Models/RefreshToken.php
new file mode 100644
index 0000000000000000000000000000000000000000..cf9a253e377096fb03cffea01c30ada9a2f57147
--- /dev/null
+++ b/lib/classes/OAuth2/Models/RefreshToken.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Studip\OAuth2\Models;
+
+/**
+ * @property int $id
+ * @property string $access_token_id
+ * @property string $client_id
+ * @property bool $revoked
+ * @property int $expires_at
+ */
+class RefreshToken extends \SimpleORMap
+{
+    use RevokedHelper;
+
+    protected static function configure($config = [])
+    {
+        $config['db_table'] = 'oauth2_refresh_tokens';
+
+        $config['belongs_to']['access_token'] = [
+            'class_name'  => AccessToken::class,
+            'foreign_key' => 'access_token_id',
+        ];
+
+        parent::configure($config);
+    }
+
+    /**
+     * Revokes refresh tokens by access token id.
+     *
+     * @param string $tokenId
+     */
+    public static function revokeByAccessTokenId($tokenId): void
+    {
+        $refreshTokens = self::findBySQL('access_token_id = ?', [$tokenId]);
+        foreach ($refreshTokens as $refreshToken) {
+            $refreshToken->revoke();
+        }
+    }
+}
diff --git a/lib/classes/OAuth2/Models/RevokedHelper.php b/lib/classes/OAuth2/Models/RevokedHelper.php
new file mode 100644
index 0000000000000000000000000000000000000000..8c973aaaa2d394e840f6b9626b0a3ff62d28e5f0
--- /dev/null
+++ b/lib/classes/OAuth2/Models/RevokedHelper.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Studip\OAuth2\Models;
+
+trait RevokedHelper
+{
+    /**
+     * @return bool
+     */
+    public function isRevoked()
+    {
+        return (bool) $this->revoked;
+    }
+
+    /**
+     * Revoke the token instance.
+     *
+     * @return void
+     */
+    public function revoke()
+    {
+        $this->revoked = 1;
+        $this->store();
+    }
+}
diff --git a/lib/classes/OAuth2/Models/Scope.php b/lib/classes/OAuth2/Models/Scope.php
new file mode 100644
index 0000000000000000000000000000000000000000..86bf81551db3109ec19e782b996b858db03ecabc
--- /dev/null
+++ b/lib/classes/OAuth2/Models/Scope.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace Studip\OAuth2\Models;
+
+class Scope
+{
+    /**
+     * @var string
+     */
+    public $id;
+
+    /**
+     * @var string
+     */
+    public $description;
+
+    /**
+     * @param string $id
+     * @param string $description
+     *
+     * @return void
+     */
+    public function __construct($id, $description)
+    {
+        $this->id = $id;
+        $this->description = $description;
+    }
+
+    /**
+     * @return static[]
+     */
+    public static function scopes()
+    {
+        return [
+            'api' => new Scope('api', _('Gewährt vollständigen Lese-/Schreibzugriff auf die API.')),
+        ];
+    }
+
+    /**
+     * @return array
+     */
+    public function toArray()
+    {
+        return [
+            'id' => $this->id,
+            'description' => $this->description,
+        ];
+    }
+
+    /**
+     * @param int $options
+     *
+     * @return string
+     */
+    public function toJson($options = 0)
+    {
+        return json_encode($this->toArray(), $options);
+    }
+}
diff --git a/lib/classes/OAuth2/NegotiatesWithPsr7.php b/lib/classes/OAuth2/NegotiatesWithPsr7.php
new file mode 100644
index 0000000000000000000000000000000000000000..0edf2431cfef7790b1e0e85d271b876d905e7c11
--- /dev/null
+++ b/lib/classes/OAuth2/NegotiatesWithPsr7.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Studip\OAuth2;
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Slim\Psr7\Response;
+use Trails_Response;
+
+trait NegotiatesWithPsr7
+{
+    protected function getPsrRequest(): ServerRequestInterface
+    {
+        return \Slim\Psr7\Factory\ServerRequestFactory::createFromGlobals();
+    }
+
+    protected function getPsrResponse(): ResponseInterface
+    {
+        return new Response();
+    }
+
+    protected function convertPsrResponse(ResponseInterface $response): Trails_Response
+    {
+        $trailsResponse = new Trails_Response((string) $response->getBody(), [], $response->getStatusCode());
+        foreach ($response->getHeaders() as $key => $values) {
+            foreach ($values as $value) {
+                $trailsResponse->add_header($key, $value);
+            }
+        }
+
+        return $trailsResponse;
+    }
+
+    protected function renderPsrResponse(ResponseInterface $response): void
+    {
+        $this->set_status($response->getStatusCode());
+        $this->render_text((string) $response->getBody());
+        foreach ($response->getHeaders() as $key => $values) {
+            foreach ($values as $value) {
+                $this->response->add_header($key, $value);
+            }
+        }
+    }
+}
diff --git a/lib/classes/OAuth2/SetupInformation.php b/lib/classes/OAuth2/SetupInformation.php
new file mode 100644
index 0000000000000000000000000000000000000000..01b0d09a578ae9aea52bed6d42b9eb3af80e3065
--- /dev/null
+++ b/lib/classes/OAuth2/SetupInformation.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Studip\OAuth2;
+
+use Psr\Container\ContainerInterface;
+
+class SetupInformation
+{
+    /** @var ContainerInterface */
+    private $container;
+
+    public function __construct(ContainerInterface $container)
+    {
+        $this->container = $container;
+    }
+
+    public function encryptionKey(): KeyInformation
+    {
+        return new KeyInformation($this->container->get('encryption_key'));
+    }
+
+    public function privateKey(): KeyInformation
+    {
+        return new KeyInformation($this->container->get('private_key'));
+    }
+
+    public function publicKey(): KeyInformation
+    {
+        return new KeyInformation($this->container->get('public_key'));
+    }
+}
diff --git a/lib/classes/PageLayout.php b/lib/classes/PageLayout.php
index ecb34b7c1222d2173b6fc9ccb361d24bd650acca..ee26d7c4e21b76263cc1834f48b09f9b5f13d50a 100644
--- a/lib/classes/PageLayout.php
+++ b/lib/classes/PageLayout.php
@@ -511,6 +511,9 @@ class PageLayout
      */
     public static function postMessage(LayoutMessage $message, $id = null)
     {
+        if (!isset($_SESSION['messages'])) {
+            $_SESSION['messages'] = [];
+        }
         if ($id === null ) {
             $_SESSION['messages'][] = $message;
         } else {
diff --git a/lib/functions.php b/lib/functions.php
index 0c30bfafd6e7c23490ca0b96b1f4648c3f8ac88c..a7f9f3a0b2fed0d65983d918c85c1ce6e1902f31 100644
--- a/lib/functions.php
+++ b/lib/functions.php
@@ -1873,3 +1873,18 @@ function encodeURI(string $uri): string
     ];
     return strtr(rawurlencode($uri), $replacements);
 }
+
+function randomString(int $length = 32): string
+{
+    $string = '';
+
+    while (($len = strlen($string)) < $length) {
+        $size = $length - $len;
+
+        $bytes = random_bytes($size);
+
+        $string .= substr(str_replace(['/', '+', '='], '', base64_encode($bytes)), 0, $size);
+    }
+
+    return $string;
+}
diff --git a/lib/navigation/AdminNavigation.php b/lib/navigation/AdminNavigation.php
index 915ceb3b48a00786be7057ac2235ac6da850179c..2081af91aa6572448bc2c1a7e27fdfa32dcd71da 100644
--- a/lib/navigation/AdminNavigation.php
+++ b/lib/navigation/AdminNavigation.php
@@ -198,6 +198,8 @@ class AdminNavigation extends Navigation
                 $navigation->addSubNavigation('api', new Navigation(_('API'), 'dispatch.php/admin/api'));
             }
 
+            $navigation->addSubNavigation('oauth2', new Navigation(_('OAuth2'), 'dispatch.php/admin/oauth2/index'));
+
             $navigation->addSubNavigation('globalsearch', new Navigation(_('Globale Suche'), 'dispatch.php/globalsearch/settings'));
             $navigation->addSubNavigation('cache', new Navigation(_('Cache'), 'dispatch.php/admin/cache/settings'));
         }
diff --git a/lib/navigation/ProfileNavigation.php b/lib/navigation/ProfileNavigation.php
index 6507c57a7de08c2caf4efc38452bec037b725143..65faca9ae6be5d0b3ef0ce24ab0d9f4860ecb030 100644
--- a/lib/navigation/ProfileNavigation.php
+++ b/lib/navigation/ProfileNavigation.php
@@ -114,6 +114,8 @@ class ProfileNavigation extends Navigation
                     $navigation->addSubNavigation('tfa', new Navigation(_('Zwei-Faktor-Authentifizierung'), 'dispatch.php/tfa'));
                 }
 
+                $navigation->addSubNavigation('oauth2', new Navigation(_('Drittanwendungen'), 'dispatch.php/api/oauth2/applications'));
+
                 $navigation->addSubNavigation('accessibility', new Navigation(
                     _('Barrierefreiheit'),
                     'dispatch.php/settings/accessibility'
diff --git a/lib/phplib/Seminar_Auth.class.php b/lib/phplib/Seminar_Auth.class.php
index bd796fabdc8870c9a48b26d16965f4557399a6e0..c1c2b860b8b57de1d884f7cce56954c3fdaa8cac 100644
--- a/lib/phplib/Seminar_Auth.class.php
+++ b/lib/phplib/Seminar_Auth.class.php
@@ -151,7 +151,7 @@ class Seminar_Auth
                 $uid = $this->auth_validatelogin();
                 if ($uid) {
                     $this->auth["uid"] = $uid;
-                    $keep_session_vars = ['auth', 'forced_language', '_language', 'contrast'];
+                    $keep_session_vars = ['auth', 'forced_language', '_language', 'contrast', 'oauth2'];
                     if ($this->auth['perm'] === 'root') {
                         $keep_session_vars[] = 'plugins_disabled';
                     }
diff --git a/public/oauth2.php b/public/oauth2.php
new file mode 100644
index 0000000000000000000000000000000000000000..18c986963b50af4641bb9d2378dd080e5f822fad
--- /dev/null
+++ b/public/oauth2.php
@@ -0,0 +1,105 @@
+<?php
+
+use DI\ContainerBuilder;
+use Slim\Factory\AppFactory;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use League\OAuth2\Server\AuthorizationServer;
+
+use Studip\OAuth2\AccessTokenRepository;
+use Studip\OAuth2\AuthCodeRepository;
+use Studip\OAuth2\ClientRepository;
+use Studip\OAuth2\ScopeRepository;
+use Studip\OAuth2\UserEntity;
+
+require '../lib/bootstrap.php';
+require '../composer/autoload.php';
+
+function addRoutes(\Slim\App $app, AuthorizationServer $server): void
+{
+    $app->get('/authorize', function (Request $request, Response $response) use ($server) {
+        try {
+            // Validate the HTTP request and return an AuthorizationRequest object.
+            $authRequest = $server->validateAuthorizationRequest($request);
+
+            // The auth request object can be serialized and saved into a user's session.
+            // You will probably want to redirect the user at this point to a login endpoint.
+            $_SESSION['oauth2_auth_request'] = serialize($authRequest);
+            var_dump($_SESSION['oauth2_auth_request']);exit;
+
+
+            // Once the user has logged in set the user on the AuthorizationRequest
+            $authRequest->setUser(new UserEntity()); // an instance of UserEntityInterface
+
+            // At this point you should redirect the user to an authorization page.
+            // This form will ask the user to approve the client and the scopes requested.
+
+            // Once the user has approved or denied the client update the status
+            // (true = approved, false = denied)
+            $authRequest->setAuthorizationApproved(true);
+
+            // Return the HTTP redirect response
+            return $server->completeAuthorizationRequest($authRequest, $response);
+        } catch (OAuthServerException $exception) {
+            // All instances of OAuthServerException can be formatted into a HTTP response
+            return $exception->generateHttpResponse($response);
+        } catch (\Exception $exception) {
+            // Unknown exception
+            $body = new Stream(fopen('php://temp', 'r+'));
+            $body->write($exception->getMessage());
+            return $response->withStatus(500)->withBody($body);
+        }
+    });
+}
+
+$clientRepository = new ClientRepository(); // instance of ClientRepositoryInterface
+$scopeRepository = new ScopeRepository(); // instance of ScopeRepositoryInterface
+$accessTokenRepository = new AccessTokenRepository(); // instance of AccessTokenRepositoryInterface
+$authCodeRepository = new AuthCodeRepository(); // instance of AuthCodeRepositoryInterface
+$refreshTokenRepository = new RefreshTokenRepository(); // instance of RefreshTokenRepositoryInterface
+
+$privateKey = 'file://path/to/private.key';
+//$privateKey = new CryptKey('file://path/to/private.key', 'passphrase'); // if private key has a pass phrase
+$encryptionKey = 'lxZFUEsBCJ2Yb14IF2ygAHI5N4+ZAUXXaSeeJm6+twsUmIen'; // generate using base64_encode(random_bytes(32))
+
+
+// Setup the authorization server
+$server = new \League\OAuth2\Server\AuthorizationServer(
+    $clientRepository,
+    $accessTokenRepository,
+    $scopeRepository,
+    $privateKey,
+    $encryptionKey
+);
+
+page_open([
+    'sess' => 'Seminar_Session',
+    'auth' => 'Seminar_Default_Auth',
+    'perm' => 'Seminar_Perm',
+    'user' => 'Seminar_User',
+]);
+
+// Set base url for URLHelper class
+URLHelper::setBaseUrl($GLOBALS['CANONICAL_RELATIVE_PATH_STUDIP']);
+
+$containerBuilder = new ContainerBuilder();
+$container = $containerBuilder->build();
+
+AppFactory::setContainer($container);
+$app = AppFactory::create();
+$container->set(\Slim\App::class, $app);
+
+$app->setBasePath('/oauth2.php');
+
+$app->addRoutingMiddleware();
+addRoutes($app, $server);
+
+$displayErrors = false;
+if (defined('\\Studip\\ENV')) {
+    $displayErrors = constant('\\Studip\\ENV') === 'development';
+}
+$logError = true;
+$logErrorDetails = true;
+$errorMiddleware = $app->addErrorMiddleware($displayErrors, $logError, $logErrorDetails);
+
+$app->run();
diff --git a/resources/assets/stylesheets/scss/oauth2.scss b/resources/assets/stylesheets/scss/oauth2.scss
new file mode 100644
index 0000000000000000000000000000000000000000..a633f1b607baf5ff8a0bec00fe2d16d66a99d043
--- /dev/null
+++ b/resources/assets/stylesheets/scss/oauth2.scss
@@ -0,0 +1,26 @@
+article.admin-oauth2--setup {
+    margin-bottom: 3em;
+}
+
+.oauth2-clients--confidentiality > div {
+    display: flex;
+    align-items: flex-start;
+}
+
+#api-oauth2-authorize-index {
+
+    font-size: 16px;
+
+    #layout-sidebar, #layout_footer {
+        display: none;
+    }
+
+    .scopes, .buttons {
+        margin-top: 2rem;
+        margin-bottom: 2rem;
+    }
+
+    .buttons {
+        display: flex;
+    }
+}
diff --git a/resources/assets/stylesheets/studip.scss b/resources/assets/stylesheets/studip.scss
index fa5805e42362bade2d99644ec6572b353f3a657b..62bc7b59ca9c451205f4a39bd91a1bcf09687373 100644
--- a/resources/assets/stylesheets/studip.scss
+++ b/resources/assets/stylesheets/studip.scss
@@ -24,6 +24,7 @@
 @import "scss/my_courses";
 @import "scss/oer";
 @import "scss/qrcode";
+@import "scss/oauth2";
 @import "scss/report";
 @import "scss/resources";
 @import "scss/sidebar";