From ccc21be9ec6c21f9dcc5d93bd3090c4abc24402a Mon Sep 17 00:00:00 2001
From: Tim van Dijen <tvdijen@gmail.com>
Date: Fri, 4 Mar 2022 17:00:44 +0100
Subject: [PATCH] Migrate logout-scripts to controllers

---
 lib/SimpleSAML/IdP.php                        |   2 +-
 modules/core/lib/Controller/Login.php         |  22 +-
 modules/core/lib/Controller/Logout.php        | 400 ++++++++++++++++++
 modules/core/routing/routes/routes.yml        |  18 +-
 .../core/templates/logout-iframe-wrapper.twig |   2 +-
 modules/core/templates/logout-iframe.twig     |   2 +-
 modules/core/www/idp/logout-iframe-done.php   |  59 ---
 modules/core/www/idp/logout-iframe-post.php   |  60 ---
 modules/core/www/idp/logout-iframe.js         | 110 -----
 modules/core/www/idp/logout-iframe.php        | 134 ------
 modules/core/www/idp/resumelogout.php         |  13 -
 .../modules/core/lib/Controller/LoginTest.php |  70 +--
 .../core/lib/Controller/LogoutTest.php        | 108 +++++
 13 files changed, 528 insertions(+), 472 deletions(-)
 create mode 100644 modules/core/lib/Controller/Logout.php
 delete mode 100644 modules/core/www/idp/logout-iframe-done.php
 delete mode 100644 modules/core/www/idp/logout-iframe-post.php
 delete mode 100644 modules/core/www/idp/logout-iframe.js
 delete mode 100644 modules/core/www/idp/logout-iframe.php
 delete mode 100644 modules/core/www/idp/resumelogout.php
 create mode 100644 tests/modules/core/lib/Controller/LogoutTest.php

diff --git a/lib/SimpleSAML/IdP.php b/lib/SimpleSAML/IdP.php
index 59fc0de1c..92943bf1a 100644
--- a/lib/SimpleSAML/IdP.php
+++ b/lib/SimpleSAML/IdP.php
@@ -482,7 +482,7 @@ class IdP
 
         // terminate the local session
         $id = Auth\State::saveState($state, 'core:Logout:afterbridge');
-        $returnTo = Module::getModuleURL('core/idp/resumelogout.php', ['id' => $id]);
+        $returnTo = Module::getModuleURL('core/logout-resume', ['id' => $id]);
 
         $this->authSource->logout($returnTo);
 
diff --git a/modules/core/lib/Controller/Login.php b/modules/core/lib/Controller/Login.php
index c38de3360..5e57eb09a 100644
--- a/modules/core/lib/Controller/Login.php
+++ b/modules/core/lib/Controller/Login.php
@@ -18,7 +18,6 @@ use SimpleSAML\XHTML\Template;
 use Symfony\Component\HttpFoundation\Cookie;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 use Symfony\Component\HttpFoundation\Request;
-use Symfony\Component\HttpFoundation\Response;
 
 use function array_key_exists;
 use function substr;
@@ -442,26 +441,6 @@ class Login
     }
 
 
-    /**
-     * Log the user out of a given authentication source.
-     *
-     * @param \Symfony\Component\HttpFoundation\Request $request
-     * @param string $as The name of the auth source.
-     *
-     * @return \SimpleSAML\HTTP\RunnableResponse A runnable response which will actually perform logout.
-     *
-     * @throws \SimpleSAML\Error\CriticalConfigurationError
-     */
-    public function logout(Request $request, string $as): RunnableResponse
-    {
-        $auth = new Auth\Simple($as);
-        $returnTo = $this->getReturnPath($request);
-        return new RunnableResponse(
-            [$auth, 'logout'],
-            [$returnTo]
-        );
-    }
-
     /**
      * Searches for a valid and allowed ReturnTo URL parameter,
      * otherwise give the base installation page as a return point.
@@ -480,6 +459,7 @@ class Login
         return $returnTo;
     }
 
+
     /**
      * This clears the user's IdP discovery choices.
      *
diff --git a/modules/core/lib/Controller/Logout.php b/modules/core/lib/Controller/Logout.php
new file mode 100644
index 000000000..039cf98cc
--- /dev/null
+++ b/modules/core/lib/Controller/Logout.php
@@ -0,0 +1,400 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\core\Controller;
+
+use Exception;
+use SAML2\Binding;
+use SAML2\Constants;
+use SimpleSAML\Auth;
+use SimpleSAML\Configuration;
+use SimpleSAML\Error;
+use SimpleSAML\HTTP\RunnableResponse;
+use SimpleSAML\IdP;
+use SimpleSAML\Logger;
+use SimpleSAML\Metadata\MetaDataStorageHandler;
+use SimpleSAML\Module\saml\Message;
+use SimpleSAML\Stats;
+use SimpleSAML\Utils;
+use SimpleSAML\XHTML\Template;
+use Symfony\Component\HttpFoundation\Request;
+
+use function call_user_func;
+use function in_array;
+use function method_exists;
+use function sha1;
+use function substr;
+use function time;
+use function urldecode;
+use function var_export;
+
+/**
+ * Controller class for the core module.
+ *
+ * This class serves the different views available in the module.
+ *
+ * @package simplesamlphp/simplesamlphp
+ */
+class Logout
+{
+    /** @var \SimpleSAML\Configuration */
+    protected Configuration $config;
+
+
+    /**
+     * Controller constructor.
+     *
+     * It initializes the global configuration for the controllers implemented here.
+     *
+     * @param \SimpleSAML\Configuration $config The configuration to use by the controllers.
+     */
+    public function __construct(
+        Configuration $config
+    ) {
+        $this->config = $config;
+    }
+
+
+    /**
+     * Log the user out of a given authentication source.
+     *
+     * @param Request $request The request that lead to this logout operation.
+     * @param string $as The name of the auth source.
+     *
+     * @return \SimpleSAML\HTTP\RunnableResponse A runnable response which will actually perform logout.
+     *
+     * @throws \SimpleSAML\Error\CriticalConfigurationError
+     */
+    public function logout(Request $request, string $as): RunnableResponse
+    {
+        $auth = new Auth\Simple($as);
+        $returnTo = $this->getReturnPath($request);
+        return new RunnableResponse(
+            [$auth, 'logout'],
+            [$returnTo]
+        );
+    }
+
+
+    /**
+     * Searches for a valid and allowed ReturnTo URL parameter,
+     * otherwise give the base installation page as a return point.
+     */
+    private function getReturnPath(Request $request): string
+    {
+        $httpUtils = new Utils\HTTP();
+
+        $returnTo = $request->query->get('ReturnTo', false);
+        if ($returnTo !== false) {
+            $returnTo = $httpUtils->checkURLAllowed($returnTo);
+        }
+
+        if (empty($returnTo)) {
+            return $this->config->getBasePath();
+        }
+
+        return $returnTo;
+    }
+
+
+    /**
+     * @param Request $request The request that lead to this logout operation.
+     * @return \SimpleSAML\HTTP\RunnableResponse
+     */
+    public function logoutIframeDone(Request $request): RunnableResponse
+    {
+        if (!$request->query->has('id')) {
+            throw new Error\BadRequest('Missing required parameter: id');
+        }
+        $id = $request->query->get('id');
+
+        /** @psalm-var array $state */
+        $state = Auth\State::loadState($id, 'core:Logout-IFrame');
+        $idp = IdP::getByState($state);
+
+        $associations = $idp->getAssociations();
+
+        if (!$request->query->has('cancel')) {
+            Logger::stats('slo-iframe done');
+            Stats::log('core:idp:logout-iframe:page', ['type' => 'done']);
+            $SPs = $state['core:Logout-IFrame:Associations'];
+        } else {
+            // user skipped global logout
+            Logger::stats('slo-iframe skip');
+            Stats::log('core:idp:logout-iframe:page', ['type' => 'skip']);
+            $SPs = []; // no SPs should have been logged out
+            $state['core:Failed'] = true; // mark as partial logout
+        }
+
+        // find the status of all SPs
+        foreach ($SPs as $assocId => &$sp) {
+            $spId = 'logout-iframe-' . sha1($assocId);
+
+            if ($request->query->has($spId)) {
+                $spStatus = $request->query->get($spId);
+                if ($spStatus === 'completed' || $spStatus === 'failed') {
+                    $sp['core:Logout-IFrame:State'] = $spStatus;
+                }
+            }
+
+            if (!isset($associations[$assocId])) {
+                $sp['core:Logout-IFrame:State'] = 'completed';
+            }
+        }
+
+
+        // terminate the associations
+        foreach ($SPs as $assocId => $sp) {
+            if ($sp['core:Logout-IFrame:State'] === 'completed') {
+                $idp->terminateAssociation($assocId);
+            } else {
+                Logger::warning('Unable to terminate association with ' . var_export($assocId, true) . '.');
+                if (isset($sp['saml:entityID'])) {
+                    $spId = $sp['saml:entityID'];
+                } else {
+                    $spId = $assocId;
+                }
+                Logger::stats('slo-iframe-fail ' . $spId);
+                Stats::log('core:idp:logout-iframe:spfail', ['sp' => $spId]);
+                $state['core:Failed'] = true;
+            }
+        }
+
+        // we are done
+        return new RunnableResponse([$idp, 'finishLogout'], [$state]);
+    }
+
+
+    /**
+     * @param Request $request The request that lead to this logout operation.
+     * @return \SimpleSAML\HTTP\RunnableResponse
+     */
+    public function logoutIframePost(Request $request): RunnableResponse
+    {
+        if (!$request->query->has('idp')) {
+            throw new Error\BadRequest('Missing required parameter: idp');
+        }
+
+        $idp = IdP::getById($request->query->get('idp'));
+
+        if (!$request->query->has('association')) {
+            throw new Error\BadRequest('Missing required parameter: association');
+        }
+        $assocId = urldecode($request->query->get('association'));
+
+        $relayState = null;
+        if ($request->query->has('RelayState')) {
+            $relayState = $request->query->get('RelayState');
+        }
+
+        $associations = $idp->getAssociations();
+        if (!isset($associations[$assocId])) {
+            throw new Error\BadRequest('Invalid association id.');
+        }
+        $association = $associations[$assocId];
+
+        $metadata = MetaDataStorageHandler::getMetadataHandler();
+        $idpMetadata = $idp->getConfig();
+        $spMetadata = $metadata->getMetaDataConfig($association['saml:entityID'], 'saml20-sp-remote');
+
+        $lr = Message::buildLogoutRequest($idpMetadata, $spMetadata);
+        $lr->setSessionIndex($association['saml:SessionIndex']);
+        $lr->setNameId($association['saml:NameID']);
+
+        $assertionLifetime = $spMetadata->getInteger('assertion.lifetime', null);
+        if ($assertionLifetime === null) {
+            $assertionLifetime = $idpMetadata->getInteger('assertion.lifetime', 300);
+        }
+        $lr->setNotOnOrAfter(time() + $assertionLifetime);
+
+        $encryptNameId = $spMetadata->getBoolean('nameid.encryption', null);
+        if ($encryptNameId === null) {
+            $encryptNameId = $idpMetadata->getBoolean('nameid.encryption', false);
+        }
+        if ($encryptNameId) {
+            $lr->encryptNameId(Message::getEncryptionKey($spMetadata));
+        }
+
+        Stats::log('saml:idp:LogoutRequest:sent', [
+            'spEntityID'  => $association['saml:entityID'],
+            'idpEntityID' => $idpMetadata->getString('entityid'),
+        ]);
+
+        $bindings = [Constants::BINDING_HTTP_POST];
+
+        /** @var array $dst */
+        $dst = $spMetadata->getDefaultEndpoint('SingleLogoutService', $bindings);
+        $binding = Binding::getBinding($dst['Binding']);
+        $lr->setDestination($dst['Location']);
+        $lr->setRelayState($relayState);
+
+        return new RunnableResponse([$binding, 'send'], [$lr]);
+    }
+
+
+    /**
+     * @param Request $request The request that lead to this logout operation.
+     * @return \SimpleSAML\XHTML\Template
+     */
+    public function logoutIframe(Request $request): Template
+    {
+        if (!$request->query->has('id')) {
+            throw new Error\BadRequest('Missing required parameter: id');
+        }
+        $id = $request->query->get('id');
+
+        $type = 'init';
+        if ($request->query->has('type')) {
+            $type = $request->query->get('type');
+            if (!in_array($type, ['init', 'js', 'nojs', 'embed'], true)) {
+                throw new Error\BadRequest('Invalid value for type.');
+            }
+        }
+
+        if ($type !== 'embed') {
+            Logger::stats('slo-iframe ' . $type);
+            Stats::log('core:idp:logout-iframe:page', ['type' => $type]);
+        }
+
+        /** @psalm-var array $state */
+        $state = Auth\State::loadState($id, 'core:Logout-IFrame');
+        $idp = IdP::getByState($state);
+        $mdh = MetaDataStorageHandler::getMetadataHandler();
+
+        if ($type !== 'init') {
+            // update association state
+            foreach ($state['core:Logout-IFrame:Associations'] as $assocId => &$sp) {
+                $spId = sha1($assocId);
+
+                // move SPs from 'onhold' to 'inprogress'
+                if ($sp['core:Logout-IFrame:State'] === 'onhold') {
+                    $sp['core:Logout-IFrame:State'] = 'inprogress';
+                }
+
+                // check for update through request
+                if ($request->query->has($spId)) {
+                    $s = $request->query->get($spId);
+                    if ($s == 'completed' || $s == 'failed') {
+                        $sp['core:Logout-IFrame:State'] = $s;
+                    }
+                }
+
+                // check for timeout
+                if (isset($sp['core:Logout-IFrame:Timeout']) && $sp['core:Logout-IFrame:Timeout'] < time()) {
+                    if ($sp['core:Logout-IFrame:State'] === 'inprogress') {
+                        $sp['core:Logout-IFrame:State'] = 'failed';
+                    }
+                }
+
+                // update the IdP
+                if ($sp['core:Logout-IFrame:State'] === 'completed') {
+                    $idp->terminateAssociation($assocId);
+                }
+
+                if (!isset($sp['core:Logout-IFrame:Timeout'])) {
+                    if (method_exists($sp['Handler'], 'getAssociationConfig')) {
+                        $assocIdP = IdP::getByState($sp);
+                        $assocConfig = call_user_func([$sp['Handler'], 'getAssociationConfig'], $assocIdP, $sp);
+                        $sp['core:Logout-IFrame:Timeout'] = $assocConfig->getInteger('core:logout-timeout', 5) + time();
+                    } else {
+                        $sp['core:Logout-IFrame:Timeout'] = time() + 5;
+                    }
+                }
+            }
+        }
+
+        $associations = $idp->getAssociations();
+        foreach ($state['core:Logout-IFrame:Associations'] as $assocId => &$sp) {
+            // in case we are refreshing a page
+            if (!isset($associations[$assocId])) {
+                $sp['core:Logout-IFrame:State'] = 'completed';
+            }
+
+            try {
+                $assocIdP = IdP::getByState($sp);
+                $url = call_user_func([$sp['Handler'], 'getLogoutURL'], $assocIdP, $sp, null);
+                $sp['core:Logout-IFrame:URL'] = $url;
+            } catch (Exception $e) {
+                $sp['core:Logout-IFrame:State'] = 'failed';
+            }
+        }
+
+        // get the metadata of the service that initiated logout, if any
+        $terminated = null;
+        if ($state['core:TerminatedAssocId'] !== null) {
+            $mdset = 'saml20-sp-remote';
+
+            if (substr($state['core:TerminatedAssocId'], 0, 4) === 'adfs') {
+                $mdset = 'adfs-sp-remote';
+            }
+
+            $terminated = $mdh->getMetaDataConfig($state['saml:SPEntityId'], $mdset)->toArray();
+        }
+
+        // build an array with information about all services currently logged in
+        $remaining = [];
+        foreach ($state['core:Logout-IFrame:Associations'] as $association) {
+            $key = sha1($association['id']);
+            $mdset = 'saml20-sp-remote';
+
+            if (substr($association['id'], 0, 4) === 'adfs') {
+                $mdset = 'adfs-sp-remote';
+            }
+
+            if ($association['core:Logout-IFrame:State'] === 'completed') {
+                continue;
+            }
+
+            $remaining[$key] = [
+                'id' => $association['id'],
+                'expires_on' => $association['Expires'],
+                'entityID' => $association['saml:entityID'],
+                'subject' => $association['saml:NameID'],
+                'status' => $association['core:Logout-IFrame:State'],
+                'metadata' => $mdh->getMetaDataConfig($association['saml:entityID'], $mdset)->toArray(),
+            ];
+
+            if (isset($association['core:Logout-IFrame:URL'])) {
+                $remaining[$key]['logoutURL'] = $association['core:Logout-IFrame:URL'];
+            }
+
+            if (isset($association['core:Logout-IFrame:Timeout'])) {
+                $remaining[$key]['timeout'] = $association['core:Logout-IFrame:Timeout'];
+            }
+        }
+
+        if ($type === 'nojs') {
+            $t = new Template($this->config, 'core:logout-iframe-wrapper.twig');
+        } else {
+            $t = new Template($this->config, 'core:logout-iframe.twig');
+        }
+
+        $t->data['auth_state'] = Auth\State::saveState($state, 'core:Logout-IFrame');
+        $t->data['type'] = $type;
+        $t->data['terminated_service'] = $terminated;
+        $t->data['remaining_services'] = $remaining;
+
+        return $t;
+    }
+
+
+    /**
+     * @param Request $request The request that lead to this logout operation.
+     * @return \SimpleSAML\HTTP\RunnableResponse
+     */
+    public function resumeLogout(Request $request): RunnableResponse
+    {
+        if (!$request->query->has('id')) {
+            throw new Error\BadRequest('Missing required parameter: id');
+        }
+        $id = $request->query->get('id');
+
+        /** @psalm-var array $state */
+        $state = Auth\State::loadState($id, 'core:Logout:afterbridge');
+        $idp = IdP::getByState($state);
+
+        $assocId = $state['core:TerminatedAssocId'];
+        return new RunnableResponse([$idp->getLogoutHandler(), 'startLogout'], [$state, $assocId]);
+    }
+}
diff --git a/modules/core/routing/routes/routes.yml b/modules/core/routing/routes/routes.yml
index 2dd3cfe2e..e1cbc08cf 100644
--- a/modules/core/routing/routes/routes.yml
+++ b/modules/core/routing/routes/routes.yml
@@ -4,9 +4,6 @@ core-welcome:
 core-account-disco-clearchoices:
     path:       /account/disco/clearchoices
     defaults:   { _controller: 'SimpleSAML\Module\core\Controller\Login:cleardiscochoices' }
-core-logout:
-    path:       /logout/{as}
-    defaults:   { _controller: 'SimpleSAML\Module\core\Controller\Login:logout' }
 core-loginuserpass:
     path:       /loginuserpass
     defaults:   { _controller: 'SimpleSAML\Module\core\Controller\Login:loginuserpass' }
@@ -37,3 +34,18 @@ core-legacy-auth:
 core-legacy-federation:
     path:       /frontpage_federation.php
     defaults:   { _controller: 'Symfony\Bundle\FrameworkBundle\Controller\RedirectController::urlRedirectAction', path: /admin/federation, permanent: true }
+core-logout:
+    path:       /logout/{as}
+    defaults:   { _controller: 'SimpleSAML\Module\core\Controller\Logout:logout' }
+core-logout-resume:
+    path:       /logout/resume
+    defaults:   { _controller: 'SimpleSAML\Module\core\Controller\Logout:logoutResume' }
+core-logout-iframe:
+    path:       /logout/iframe
+    defaults:   { _controller: 'SimpleSAML\Module\core\Controller\Logout:logoutIframe' }
+core-logout-iframe-done:
+    path:       /logout/iframe-done
+    defaults:   { _controller: 'SimpleSAML\Module\core\Controller\Logout:logoutIframeDone' }
+core-logout-iframe-post:
+    path:       /logout/iframe-post
+    defaults:   { _controller: 'SimpleSAML\Module\core\Controller\Logout:logoutIframePost' }
diff --git a/modules/core/templates/logout-iframe-wrapper.twig b/modules/core/templates/logout-iframe-wrapper.twig
index a1baa200c..6ff97956a 100644
--- a/modules/core/templates/logout-iframe-wrapper.twig
+++ b/modules/core/templates/logout-iframe-wrapper.twig
@@ -1,2 +1,2 @@
-{% set pagetitle = '{logout:progress}'|trans %}
+{% set pagetitle = 'Logging out...'|trans %}
 {% extends "@core/logout-iframe.twig" %}
diff --git a/modules/core/templates/logout-iframe.twig b/modules/core/templates/logout-iframe.twig
index f0b2cb715..7779b0255 100644
--- a/modules/core/templates/logout-iframe.twig
+++ b/modules/core/templates/logout-iframe.twig
@@ -7,7 +7,7 @@
     {%- if type != "init" %}
       {%- set content = '2' %}
       {%- if remaining_services|length == 0 %}
-        {%- set content = '0; url=logout-iframe-done.php?id=' ~ auth_state %}
+        {%- set content = '0; url=module.php/core/logout/iframe-done?id=' ~ auth_state %}
       {%- endif %}
 
     <meta http-equiv="refresh" content="{{ content }}">
diff --git a/modules/core/www/idp/logout-iframe-done.php b/modules/core/www/idp/logout-iframe-done.php
deleted file mode 100644
index 2e5c759b0..000000000
--- a/modules/core/www/idp/logout-iframe-done.php
+++ /dev/null
@@ -1,59 +0,0 @@
-<?php
-
-if (!isset($_REQUEST['id'])) {
-    throw new \SimpleSAML\Error\BadRequest('Missing required parameter: id');
-}
-
-$state = \SimpleSAML\Auth\State::loadState($_REQUEST['id'], 'core:Logout-IFrame');
-$idp = \SimpleSAML\IdP::getByState($state);
-
-$associations = $idp->getAssociations();
-
-if (!isset($_REQUEST['cancel'])) {
-    \SimpleSAML\Logger::stats('slo-iframe done');
-    \SimpleSAML\Stats::log('core:idp:logout-iframe:page', ['type' => 'done']);
-    $SPs = $state['core:Logout-IFrame:Associations'];
-} else {
-    // user skipped global logout
-    \SimpleSAML\Logger::stats('slo-iframe skip');
-    \SimpleSAML\Stats::log('core:idp:logout-iframe:page', ['type' => 'skip']);
-    $SPs = []; // no SPs should have been logged out
-    $state['core:Failed'] = true; // mark as partial logout
-}
-
-// find the status of all SPs
-foreach ($SPs as $assocId => &$sp) {
-    $spId = 'logout-iframe-' . sha1($assocId);
-
-    if (isset($_REQUEST[$spId])) {
-        $spStatus = $_REQUEST[$spId];
-        if ($spStatus === 'completed' || $spStatus === 'failed') {
-            $sp['core:Logout-IFrame:State'] = $spStatus;
-        }
-    }
-
-    if (!isset($associations[$assocId])) {
-        $sp['core:Logout-IFrame:State'] = 'completed';
-    }
-}
-
-
-// terminate the associations
-foreach ($SPs as $assocId => $sp) {
-    if ($sp['core:Logout-IFrame:State'] === 'completed') {
-        $idp->terminateAssociation($assocId);
-    } else {
-        \SimpleSAML\Logger::warning('Unable to terminate association with ' . var_export($assocId, true) . '.');
-        if (isset($sp['saml:entityID'])) {
-            $spId = $sp['saml:entityID'];
-        } else {
-            $spId = $assocId;
-        }
-        \SimpleSAML\Logger::stats('slo-iframe-fail ' . $spId);
-        \SimpleSAML\Stats::log('core:idp:logout-iframe:spfail', ['sp' => $spId]);
-        $state['core:Failed'] = true;
-    }
-}
-
-// we are done
-$idp->finishLogout($state);
diff --git a/modules/core/www/idp/logout-iframe-post.php b/modules/core/www/idp/logout-iframe-post.php
deleted file mode 100644
index d20af29fc..000000000
--- a/modules/core/www/idp/logout-iframe-post.php
+++ /dev/null
@@ -1,60 +0,0 @@
-<?php
-
-if (!isset($_REQUEST['idp'])) {
-    throw new \SimpleSAML\Error\BadRequest('Missing "idp" parameter.');
-}
-$idp = (string) $_REQUEST['idp'];
-$idp = \SimpleSAML\IdP::getById($idp);
-
-if (!isset($_REQUEST['association'])) {
-    throw new \SimpleSAML\Error\BadRequest('Missing "association" parameter.');
-}
-$assocId = urldecode($_REQUEST['association']);
-
-$relayState = null;
-if (isset($_REQUEST['RelayState'])) {
-    $relayState = (string) $_REQUEST['RelayState'];
-}
-
-$associations = $idp->getAssociations();
-if (!isset($associations[$assocId])) {
-    throw new \SimpleSAML\Error\BadRequest('Invalid association id.');
-}
-$association = $associations[$assocId];
-
-$metadata = \SimpleSAML\Metadata\MetaDataStorageHandler::getMetadataHandler();
-$idpMetadata = $idp->getConfig();
-$spMetadata = $metadata->getMetaDataConfig($association['saml:entityID'], 'saml20-sp-remote');
-
-$lr = \SimpleSAML\Module\saml\Message::buildLogoutRequest($idpMetadata, $spMetadata);
-$lr->setSessionIndex($association['saml:SessionIndex']);
-$lr->setNameId($association['saml:NameID']);
-
-$assertionLifetime = $spMetadata->getOptionalInteger('assertion.lifetime', null);
-if ($assertionLifetime === null) {
-    $assertionLifetime = $idpMetadata->getOptionalInteger('assertion.lifetime', 300);
-}
-$lr->setNotOnOrAfter(time() + $assertionLifetime);
-
-$encryptNameId = $spMetadata->getOptionalBoolean('nameid.encryption', null);
-if ($encryptNameId === null) {
-    $encryptNameId = $idpMetadata->getOptionalBoolean('nameid.encryption', false);
-}
-if ($encryptNameId) {
-    $lr->encryptNameId(\SimpleSAML\Module\saml\Message::getEncryptionKey($spMetadata));
-}
-
-\SimpleSAML\Stats::log('saml:idp:LogoutRequest:sent', [
-    'spEntityID'  => $association['saml:entityID'],
-    'idpEntityID' => $idpMetadata->getString('entityid'),
-]);
-
-$bindings = [\SAML2\Constants::BINDING_HTTP_POST];
-
-/** @var array $dst */
-$dst = $spMetadata->getDefaultEndpoint('SingleLogoutService', $bindings);
-$binding = \SAML2\Binding::getBinding($dst['Binding']);
-$lr->setDestination($dst['Location']);
-$lr->setRelayState($relayState);
-
-$binding->send($lr);
diff --git a/modules/core/www/idp/logout-iframe.js b/modules/core/www/idp/logout-iframe.js
deleted file mode 100644
index 62e7666b0..000000000
--- a/modules/core/www/idp/logout-iframe.js
+++ /dev/null
@@ -1,110 +0,0 @@
-/**
- * This function updates the global logout status.
- */
-function updateStatus()
-{
-    var nFailed = 0;
-    var nProgress = 0;
-    for (var sp in window.spStatus) {
-        switch (window.spStatus[sp]) {
-            case 'failed':
-                nFailed += 1;
-                break;
-            case 'inprogress':
-                nProgress += 1;
-                break;
-        }
-    }
-
-    if (nFailed > 0) {
-        $('#logout-failed-message').show();
-    }
-
-    if (nProgress === 0 && nFailed === 0) {
-        $('#logout-completed').show();
-        $('#done-form').submit();
-    }
-}
-
-/**
- * This function updates the logout status for a given SP.
- *
- * @param spId The ID of the SP.
- * @param status The new status.
- * @param reason The reason for the status change.
- */
-function updateSPStatus(spId, status, reason)
-{
-    if (window.spStatus[spId] === status) {
-        // unchanged
-        return;
-    }
-
-    $('#statusimage-' + spId).attr('src', window.stateImage[status]).attr('alt', window.stateText[status]).attr('title', reason);
-    window.spStatus[spId] = status;
-
-    var formId = 'logout-iframe-' + spId;
-    var existing = $('input[name="' + formId + '"]');
-    if (existing.length === 0) {
-        // don't have an existing form element, add one
-        var elementHTML = '<input type="hidden" name="' + formId + '" value="' + status + '" />';
-        $('#failed-form , #done-form').append(elementHTML);
-    } else {
-        // update existing element
-        existing.attr('value', status);
-    }
-
-    updateStatus();
-}
-
-/**
- * Mark logout as completed for an SP.
- *
- * This method will be called by the SimpleSAML\IdP\IFrameLogoutHandler class upon successful logout from the SP.
- *
- * @param spId The SP that completed logout successfully.
- */
-function logoutCompleted(spId)
-{
-    updateSPStatus(spId, 'completed', '');
-}
-
-/**
- * Mark logout as failed for an SP.
- *
- * This method will be called by the SimpleSAML\IdP\IFrameLogoutHandler class upon logout failure from the SP.
- *
- * @param spId The SP that failed to complete logout.
- * @param reason The reason why logout failed.
- */
-function logoutFailed(spId, reason)
-{
-    updateSPStatus(spId, 'failed', reason);
-}
-
-/**
- * Set timeouts for all logout operations.
- *
- * If an SP didn't reply by the timeout, we'll mark it as failed.
- */
-function timeoutSPs()
-{
-    var cTime = ((new Date()).getTime() - window.startTime) / 1000;
-    for (var sp in window.spStatus) {
-        if (window.spTimeout[sp] <= cTime && window.spStatus[sp] === 'inprogress') {
-            logoutFailed(sp, 'Timeout');
-        }
-    }
-    window.timeoutID = window.setTimeout(timeoutSPs, 1000);
-}
-
-$('document').ready(function () {
-    window.startTime = (new Date()).getTime();
-    if (window.type === 'js') {
-        window.timeoutID = window.setTimeout(timeoutSPs, 1000);
-        updateStatus();
-    } else if (window.type === 'init') {
-        $('#logout-type-selector').attr('value', 'js');
-        $('#logout-all').focus();
-    }
-});
diff --git a/modules/core/www/idp/logout-iframe.php b/modules/core/www/idp/logout-iframe.php
deleted file mode 100644
index 34c5ae32e..000000000
--- a/modules/core/www/idp/logout-iframe.php
+++ /dev/null
@@ -1,134 +0,0 @@
-<?php
-
-if (!isset($_REQUEST['id'])) {
-    throw new \SimpleSAML\Error\BadRequest('Missing required parameter: id');
-}
-
-if (isset($_REQUEST['type'])) {
-    $type = (string) $_REQUEST['type'];
-    if (!in_array($type, ['init', 'js', 'nojs', 'embed'], true)) {
-        throw new \SimpleSAML\Error\BadRequest('Invalid value for type.');
-    }
-} else {
-    $type = 'init';
-}
-
-if ($type !== 'embed') {
-    \SimpleSAML\Logger::stats('slo-iframe ' . $type);
-    \SimpleSAML\Stats::log('core:idp:logout-iframe:page', ['type' => $type]);
-}
-
-$state = \SimpleSAML\Auth\State::loadState($_REQUEST['id'], 'core:Logout-IFrame');
-$idp = \SimpleSAML\IdP::getByState($state);
-$mdh = \SimpleSAML\Metadata\MetaDataStorageHandler::getMetadataHandler();
-
-if ($type !== 'init') {
-    // update association state
-    foreach ($state['core:Logout-IFrame:Associations'] as $assocId => &$sp) {
-        $spId = sha1($assocId);
-
-        // move SPs from 'onhold' to 'inprogress'
-        if ($sp['core:Logout-IFrame:State'] === 'onhold') {
-            $sp['core:Logout-IFrame:State'] = 'inprogress';
-        }
-
-        // check for update through request
-        if (isset($_REQUEST[$spId])) {
-            $s = $_REQUEST[$spId];
-            if ($s == 'completed' || $s == 'failed') {
-                $sp['core:Logout-IFrame:State'] = $s;
-            }
-        }
-
-        // check for timeout
-        if (isset($sp['core:Logout-IFrame:Timeout']) && $sp['core:Logout-IFrame:Timeout'] < time()) {
-            if ($sp['core:Logout-IFrame:State'] === 'inprogress') {
-                $sp['core:Logout-IFrame:State'] = 'failed';
-            }
-        }
-
-        // update the IdP
-        if ($sp['core:Logout-IFrame:State'] === 'completed') {
-            $idp->terminateAssociation($assocId);
-        }
-
-        if (!isset($sp['core:Logout-IFrame:Timeout'])) {
-            if (method_exists($sp['Handler'], 'getAssociationConfig')) {
-                $assocIdP = \SimpleSAML\IdP::getByState($sp);
-                $assocConfig = call_user_func([$sp['Handler'], 'getAssociationConfig'], $assocIdP, $sp);
-                $sp['core:Logout-IFrame:Timeout'] = $assocConfig->getInteger('core:logout-timeout', 5) + time();
-            } else {
-                $sp['core:Logout-IFrame:Timeout'] = time() + 5;
-            }
-        }
-    }
-}
-
-$associations = $idp->getAssociations();
-foreach ($state['core:Logout-IFrame:Associations'] as $assocId => &$sp) {
-    // in case we are refreshing a page
-    if (!isset($associations[$assocId])) {
-        $sp['core:Logout-IFrame:State'] = 'completed';
-    }
-
-    try {
-        $assocIdP = \SimpleSAML\IdP::getByState($sp);
-        $url = call_user_func([$sp['Handler'], 'getLogoutURL'], $assocIdP, $sp, null);
-        $sp['core:Logout-IFrame:URL'] = $url;
-    } catch (\Exception $e) {
-        $sp['core:Logout-IFrame:State'] = 'failed';
-    }
-}
-
-// get the metadata of the service that initiated logout, if any
-$terminated = null;
-if ($state['core:TerminatedAssocId'] !== null) {
-    $mdset = 'saml20-sp-remote';
-    if (substr($state['core:TerminatedAssocId'], 0, 4) === 'adfs') {
-        $mdset = 'adfs-sp-remote';
-    }
-    $terminated = $mdh->getMetaDataConfig($state['saml:SPEntityId'], $mdset)->toArray();
-}
-
-// build an array with information about all services currently logged in
-$remaining = [];
-foreach ($state['core:Logout-IFrame:Associations'] as $association) {
-    $key = sha1($association['id']);
-    $mdset = 'saml20-sp-remote';
-    if (substr($association['id'], 0, 4) === 'adfs') {
-        $mdset = 'adfs-sp-remote';
-    }
-
-    if ($association['core:Logout-IFrame:State'] === 'completed') {
-        continue;
-    }
-
-    $remaining[$key] = [
-        'id' => $association['id'],
-        'expires_on' => $association['Expires'],
-        'entityID' => $association['saml:entityID'],
-        'subject' => $association['saml:NameID'],
-        'status' => $association['core:Logout-IFrame:State'],
-        'metadata' => $mdh->getMetaDataConfig($association['saml:entityID'], $mdset)->toArray(),
-    ];
-    if (isset($association['core:Logout-IFrame:URL'])) {
-        $remaining[$key]['logoutURL'] = $association['core:Logout-IFrame:URL'];
-    }
-    if (isset($association['core:Logout-IFrame:Timeout'])) {
-        $remaining[$key]['timeout'] = $association['core:Logout-IFrame:Timeout'];
-    }
-}
-
-$globalConfig = \SimpleSAML\Configuration::getInstance();
-if ($type === 'nojs') {
-    $t = new \SimpleSAML\XHTML\Template($globalConfig, 'core:logout-iframe-wrapper.twig');
-} else {
-    $t = new \SimpleSAML\XHTML\Template($globalConfig, 'core:logout-iframe.twig');
-}
-
-$id = \SimpleSAML\Auth\State::saveState($state, 'core:Logout-IFrame');
-$t->data['auth_state'] = $id;
-$t->data['type'] = $type;
-$t->data['terminated_service'] = $terminated;
-$t->data['remaining_services'] = $remaining;
-$t->send();
diff --git a/modules/core/www/idp/resumelogout.php b/modules/core/www/idp/resumelogout.php
deleted file mode 100644
index 656885f44..000000000
--- a/modules/core/www/idp/resumelogout.php
+++ /dev/null
@@ -1,13 +0,0 @@
-<?php
-
-if (!isset($_REQUEST['id'])) {
-    throw new \SimpleSAML\Error\BadRequest('Missing id-parameter.');
-}
-
-$state = \SimpleSAML\Auth\State::loadState($_REQUEST['id'], 'core:Logout:afterbridge');
-$idp = \SimpleSAML\IdP::getByState($state);
-
-$assocId = $state['core:TerminatedAssocId'];
-
-$handler = $idp->getLogoutHandler();
-$handler->startLogout($state, $assocId);
diff --git a/tests/modules/core/lib/Controller/LoginTest.php b/tests/modules/core/lib/Controller/LoginTest.php
index e1e080ece..52d3ed18d 100644
--- a/tests/modules/core/lib/Controller/LoginTest.php
+++ b/tests/modules/core/lib/Controller/LoginTest.php
@@ -71,77 +71,9 @@ class LoginTest extends ClearStateTestCase
         $this->assertEquals('core:welcome.twig', $response->getTemplateName());
     }
 
-    /**
-     * Test basic operation of the logout controller.
-     * @TODO check if the passed auth source is correctly used
-     */
-    public function testLogout(): void
-    {
-        $request = Request::create(
-            '/logout',
-            'GET',
-            [],
-        );
-
-        $c = new Controller\Login($this->config);
-        $response = $c->logout($request, 'example-authsource');
-
-        $this->assertInstanceOf(RunnableResponse::class, $response);
-        $callable = $response->getCallable();
-        $this->assertInstanceOf(\SimpleSAML\Auth\Simple::class, $callable[0]);
-        $this->assertEquals('logout', $callable[1]);
-    }
-
 
     /**
      */
-    public function testLoginUserPassNoState(): void
-    {
-        $request = Request::create(
-            '/loginuserpass',
-            'GET',
-            [],
-        );
-
-        $c = new Controller\Login($this->config);
-
-        $this->expectException(Error\BadRequest::class);
-        $c->loginuserpass($request);
-    }
-
-
-    public function testLogoutReturnToDisallowedUrlRejected(): void
-    {
-        $request = Request::create(
-            '/logout/example-authsource',
-            'GET',
-            ['ReturnTo' => 'https://loeki.tv/asjemenou'],
-        );
-        $_SERVER['REQUEST_URI']  = 'https://example.com/simplesaml/module.php/core/logout/example-authsource';
-
-        $c = new Controller\Login($this->config);
-
-        $this->expectException(Exception::class);
-        $this->expectExceptionMessage('URL not allowed: https://loeki.tv/asjemenou');
-        $response = $c->logout($request, 'example-authsource');
-    }
-
-    public function testLogoutReturnToAllowedUrl(): void
-    {
-        $request = Request::create(
-            '/logout/example-authsource',
-            'GET',
-            ['ReturnTo' => 'https://example.org/something'],
-        );
-        $_SERVER['REQUEST_URI']  = 'https://example.com/simplesaml/module.php/core/logout/example-authsource';
-
-        $c = new Controller\Login($this->config);
-
-        $response = $c->logout($request, 'example-authsource');
-        $this->assertInstanceOf(RunnableResponse::class, $response);
-        $this->assertEquals('https://example.org/something', $response->getArguments()[0]);
-    }
-
     public function testClearDiscoChoicesReturnToDisallowedUrlRejected(): void
     {
         $request = Request::create(
@@ -153,7 +85,7 @@ class LoginTest extends ClearStateTestCase
 
         $c = new Controller\Login($this->config);
 
-        $this->expectException(Exception::class);
+        $this->expectException(Error\Exception::class);
         $this->expectExceptionMessage('URL not allowed: https://loeki.tv/asjemenou');
         $response = $c->cleardiscochoices($request);
     }
diff --git a/tests/modules/core/lib/Controller/LogoutTest.php b/tests/modules/core/lib/Controller/LogoutTest.php
new file mode 100644
index 000000000..6fb32b175
--- /dev/null
+++ b/tests/modules/core/lib/Controller/LogoutTest.php
@@ -0,0 +1,108 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\core\Controller;
+
+use SimpleSAML\Auth;
+use SimpleSAML\Configuration;
+use SimpleSAML\Error;
+use SimpleSAML\HTTP\RunnableResponse;
+//use SimpleSAML\Locale\Localization;
+use SimpleSAML\Module\core\Controller;
+use SimpleSAML\TestUtils\ClearStateTestCase;
+//use SimpleSAML\XHTML\Template;
+//use Symfony\Component\HttpFoundation\RedirectResponse;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Set of tests for the controllers in the "core" module.
+ *
+ * For now, this test extends ClearStateTestCase so that it doesn't interfere with other tests. Once every class has
+ * been made PSR-7-aware, that won't be necessary any longer.
+ *
+ * @covers \SimpleSAML\Module\core\Controller\Logout
+ * @package SimpleSAML\Test
+ */
+class LogoutTest extends ClearStateTestCase
+{
+    /** @var \SimpleSAML\Configuration */
+    protected Configuration $config;
+
+    /** @var \SimpleSAML\Configuration[] */
+    protected array $loadedConfigs;
+
+
+    /**
+     * Set up for each test.
+     */
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $this->config = Configuration::loadFromArray(
+            [
+                'baseurlpath' => 'https://example.org/simplesaml',
+                'module.enable' => ['exampleauth' => true],
+            ],
+            '[ARRAY]',
+            'simplesaml'
+        );
+        Configuration::setPreLoadedConfig($this->config, 'config.php');
+    }
+
+
+    /**
+     * Test basic operation of the logout controller.
+     * @TODO check if the passed auth source is correctly used
+     */
+    public function testLogout(): void
+    {
+        $request = Request::create(
+            '/logout',
+            'GET',
+        );
+
+        $c = new Controller\Logout($this->config);
+
+        $response = $c->logout($request, 'example-authsource');
+
+        $this->assertInstanceOf(RunnableResponse::class, $response);
+        $callable = $response->getCallable();
+        $this->assertInstanceOf(Auth\Simple::class, $callable[0]);
+        $this->assertEquals('logout', $callable[1]);
+    }
+
+
+    public function testLogoutReturnToDisallowedUrlRejected(): void
+    {
+        $request = Request::create(
+            '/logout/example-authsource',
+            'GET',
+            ['ReturnTo' => 'https://loeki.tv/asjemenou'],
+        );
+        $_SERVER['REQUEST_URI']  = 'https://example.com/simplesaml/module.php/core/logout/example-authsource';
+
+        $c = new Controller\Logout($this->config);
+
+        $this->expectException(Error\Exception::class);
+        $this->expectExceptionMessage('URL not allowed: https://loeki.tv/asjemenou');
+        $response = $c->logout($request, 'example-authsource');
+    }
+
+
+    public function testLogoutReturnToAllowedUrl(): void
+    {
+        $request = Request::create(
+            '/logout/example-authsource',
+            'GET',
+            ['ReturnTo' => 'https://example.org/something'],
+        );
+        $_SERVER['REQUEST_URI']  = 'https://example.com/simplesaml/module.php/core/logout/example-authsource';
+
+        $c = new Controller\Logout($this->config);
+
+        $response = $c->logout($request, 'example-authsource');
+        $this->assertInstanceOf(RunnableResponse::class, $response);
+        $this->assertEquals('https://example.org/something', $response->getArguments()[0]);
+    }
+}
-- 
GitLab