diff --git a/modules/multiauth/lib/Auth/Source/MultiAuth.php b/modules/multiauth/lib/Auth/Source/MultiAuth.php
index 9e5273146c489bd01d28d0f9f3f987681558e663..3304ad27456f44b64a255af4b986b9767e1a97ea 100644
--- a/modules/multiauth/lib/Auth/Source/MultiAuth.php
+++ b/modules/multiauth/lib/Auth/Source/MultiAuth.php
@@ -10,6 +10,7 @@ use SimpleSAML\Assert\Assert;
 use SimpleSAML\Auth;
 use SimpleSAML\Configuration;
 use SimpleSAML\Error;
+use SimpleSAML\HTTP\RunnableResponse;
 use SimpleSAML\Module;
 use SimpleSAML\Session;
 use SimpleSAML\Utils;
@@ -187,7 +188,7 @@ class MultiAuth extends Auth\Source
         /* Redirect to the select source page. We include the identifier of the
          * saved state array as a parameter to the login form
          */
-        $url = Module::getModuleURL('multiauth/selectsource.php');
+        $url = Module::getModuleURL('multiauth/discovery');
         $params = ['AuthState' => $id];
 
         // Allows the user to specify the auth source to be used
@@ -212,9 +213,10 @@ class MultiAuth extends Auth\Source
      *
      * @param string $authId Selected authentication source
      * @param array $state Information about the current authentication.
+     * @return \SimpleSAML\HTTP\RunnableResponse
      * @throws \Exception
      */
-    public static function delegateAuthentication(string $authId, array $state): void
+    public static function delegateAuthentication(string $authId, array $state): RunnableResponse
     {
         $as = Auth\Source::getById($authId);
         $valid_sources = array_map(
@@ -240,6 +242,17 @@ class MultiAuth extends Auth\Source
             Session::DATA_TIMEOUT_SESSION_END
         );
 
+        return new RunnableResponse([self::class, 'doAuthentication'], [$as, $state]);
+    }
+
+
+    /**
+     * @param \SimpleSAML\Auth\Source $as
+     * @param array $state
+     * @return void
+     */
+    public static function doAuthentication(Auth\Source $as, array $state): void
+    {
         try {
             $as->authenticate($state);
         } catch (Error\Exception $e) {
diff --git a/modules/multiauth/lib/Controller/DiscoController.php b/modules/multiauth/lib/Controller/DiscoController.php
new file mode 100644
index 0000000000000000000000000000000000000000..e4d3eba97124092c83cfff739e28997d37330c18
--- /dev/null
+++ b/modules/multiauth/lib/Controller/DiscoController.php
@@ -0,0 +1,167 @@
+<?php
+
+namespace SimpleSAML\Module\multiauth\Controller;
+
+use SimpleSAML\Auth;
+use SimpleSAML\Auth\AuthenticationFactory;
+use SimpleSAML\Configuration;
+use SimpleSAML\Error;
+use SimpleSAML\HTTP\RunnableResponse;
+use SimpleSAML\Logger;
+use SimpleSAML\Module;
+use SimpleSAML\Module\multiauth\Auth\Source\MultiAuth;
+use SimpleSAML\Session;
+use SimpleSAML\Utils;
+use SimpleSAML\XHTML\Template;
+use Symfony\Component\HttpFoundation\RedirectResponse;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Controller class for the multiauth module.
+ *
+ * This class serves the different views available in the module.
+ *
+ * @package SimpleSAML\Module\multiauth
+ */
+class DiscoController
+{
+    /** @var \SimpleSAML\Configuration */
+    protected $config;
+
+    /** @var \SimpleSAML\Session */
+    protected $session;
+
+    /**
+     * @var \SimpleSAML\Auth\Source|string
+     * @psalm-var \SimpleSAML\Auth\Source|class-string
+     */
+    protected $authSource = Auth\Source::class;
+
+    /**
+     * @var \SimpleSAML\Auth\State|string
+     * @psalm-var \SimpleSAML\Auth\State|class-string
+     */
+    protected $authState = Auth\State::class;
+
+
+    /**
+     * Controller constructor.
+     *
+     * It initializes the global configuration and auth source configuration for the controllers implemented here.
+     *
+     * @param \SimpleSAML\Configuration              $config The configuration to use by the controllers.
+     * @param \SimpleSAML\Session                    $session The session to use by the controllers.
+     *
+     * @throws \Exception
+     */
+    public function __construct(
+        Configuration $config,
+        Session $session
+    ) {
+        $this->config = $config;
+        $this->session = $session;
+    }
+
+
+    /**
+     * Inject the \SimpleSAML\Auth\Source dependency.
+     *
+     * @param \SimpleSAML\Auth\Source $authSource
+     */
+    public function setAuthSource(Auth\Source $authSource): void
+    {
+        $this->authSource = $authSource;
+    }
+
+
+    /**
+     * Inject the \SimpleSAML\Auth\State dependency.
+     *
+     * @param \SimpleSAML\Auth\State $authState
+     */
+    public function setAuthState(Auth\State $authState): void
+    {
+        $this->authState = $authState;
+    }
+
+
+    /**
+     * This controller shows a list of authentication sources. When the user selects
+     * one of them if pass this information to the
+     * \SimpleSAML\Module\multiauth\Auth\Source\MultiAuth class and call the
+     * delegateAuthentication method on it.
+     *
+     * @param \Symfony\Component\HttpFoundation\Request $request
+     * @return \SimpleSAML\XHTML\Template|\SimpleSAML\HTTP\RunnableResponse
+     *   An HTML template or a redirection if we are not authenticated.
+     */
+    public function discovery(Request $request)
+    {
+        // Retrieve the authentication state
+        $authStateId = $request->get('AuthState', null);
+        if (is_null($authStateId)) {
+            throw new Error\BadRequest('Missing AuthState parameter.');
+        }
+
+        /** @var array $state */
+        $state = $this->authState::loadState($authStateId, MultiAuth::STAGEID);
+
+        $as = null;
+        if (array_key_exists("\SimpleSAML\Auth\Source.id", $state)) {
+            $authId = $state["\SimpleSAML\Auth\Source.id"];
+
+            /** @var \SimpleSAML\Module\multiauth\Auth\Source\MultiAuth $as */
+            $as = Auth\Source::getById($authId);
+        }
+
+        $source = $request->get('source', null);
+
+        if ($source !== null) {
+            if ($as !== null) {
+                $as->setPreviousSource($source);
+            }
+            return MultiAuth::delegateAuthentication($source, $state);
+        }
+
+        if (array_key_exists('multiauth:preselect', $state)) {
+            $source = $state['multiauth:preselect'];
+            return MultiAuth::delegateAuthentication($source, $state);
+        }
+
+        $t = new Template($this->config, 'multiauth:selectsource.twig');
+
+        $defaultLanguage = $this->config->getString('language.default', 'en');
+        $language = $t->getTranslator()->getLanguage()->getLanguage();
+
+        $sources = $state[MultiAuth::SOURCESID];
+        foreach ($sources as $key => $source) {
+            $sources[$key]['source64'] = base64_encode($sources[$key]['source']);
+            if (isset($sources[$key]['text'][$language])) {
+                $sources[$key]['text'] = $sources[$key]['text'][$language];
+            } else {
+                $sources[$key]['text'] = $sources[$key]['text'][$defaultLanguage];
+            }
+
+            if (isset($sources[$key]['help'][$language])) {
+                $sources[$key]['help'] = $sources[$key]['help'][$language];
+            } else {
+                $sources[$key]['help'] = $sources[$key]['help'][$defaultLanguage];
+            }
+        }
+
+        $baseurl = explode("/", Utils\HTTP::getBaseURL());
+        $elements = array_slice($baseurl, 3 - count($baseurl), count($baseurl) - 4);
+        $path = implode("/", $elements);
+
+        $t->data['selfUrl'] = '/' . $path;
+        $t->data['authstate'] = $authStateId;
+        $t->data['sources'] = $sources;
+
+        if ($as !== null) {
+            $t->data['preferred'] = $as->getPreviousSource();
+        } else {
+            $t->data['preferred'] = null;
+        }
+        return $t;
+    }
+}
diff --git a/modules/multiauth/routing/routes/routes.yaml b/modules/multiauth/routing/routes/routes.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..0bf81d9bde16e7d2fd61330ac66efcc9a3d9e5d2
--- /dev/null
+++ b/modules/multiauth/routing/routes/routes.yaml
@@ -0,0 +1,3 @@
+multiauth-discovery:
+    path:       /discovery
+    defaults:   { _controller: 'SimpleSAML\Module\multiauth\Controller\DiscoController::discovery' }
diff --git a/modules/multiauth/templates/selectsource.twig b/modules/multiauth/templates/selectsource.twig
index 7b212cbd85858a893ab38085c3d03680a1eb611a..aa7482c884aef8c22a41fd5a0a7f17d3cb6d8826 100644
--- a/modules/multiauth/templates/selectsource.twig
+++ b/modules/multiauth/templates/selectsource.twig
@@ -9,10 +9,9 @@
         <input type="hidden" name="AuthState" value="{{ authstate|escape('html') }}">
         <ul>
         {% for key, source in sources %}
-            {% set name = ('src-' ~ source.source64) %}
             {% set button = ('button-' ~ source.source) %}
 	    <li class="{{ source.css_class|escape('html') }} authsource">
-            <input type="submit" name="{{ name|escape('html') }}" id="{{ button|escape('html') }}" value="{{ source.text|escape('html') }}"{%- if source.source == preferred %} autofocus{% endif -%}>
+            <input type="submit" name="source" id="{{ button|escape('html') }}" value="{{ source.text|escape('html') }}"{%- if source.source == preferred %} autofocus{% endif -%}>
             {% if source.help %}
               <p>{{ source.help|escape('html') }}</p>
             {% endif %}
diff --git a/modules/multiauth/www/selectsource.php b/modules/multiauth/www/selectsource.php
index fcc5a96729165b65ec544a8082d217fab8147b57..6e7c870ed290ff91253b9ed51b44b16eb1bef049 100644
--- a/modules/multiauth/www/selectsource.php
+++ b/modules/multiauth/www/selectsource.php
@@ -9,76 +9,16 @@
  * @package SimpleSAMLphp
  */
 
-// Retrieve the authentication state
-if (!array_key_exists('AuthState', $_REQUEST)) {
-    throw new \SimpleSAML\Error\BadRequest('Missing AuthState parameter.');
-}
-$authStateId = $_REQUEST['AuthState'];
+namespace SimpleSAML\Module\multiauth;
 
-/** @var array $state */
-$state = \SimpleSAML\Auth\State::loadState($authStateId, \SimpleSAML\Module\multiauth\Auth\Source\MultiAuth::STAGEID);
+use SimpleSAML\Configuration;
+use SimpleSAML\Session;
+use Symfony\Component\HttpFoundation\Request;
 
-if (array_key_exists("\SimpleSAML\Auth\Source.id", $state)) {
-    $authId = $state["\SimpleSAML\Auth\Source.id"];
-    /** @var \SimpleSAML\Module\multiauth\Auth\Source\MultiAuth $as */
-    $as = \SimpleSAML\Auth\Source::getById($authId);
-} else {
-    $as = null;
-}
+$config = Configuration::getInstance();
+$session = Session::getSessionFromRequest();
+$request = Request::createFromGlobals();
 
-$source = null;
-if (array_key_exists('source', $_REQUEST)) {
-    $source = $_REQUEST['source'];
-} else {
-    foreach ($_REQUEST as $k => $v) {
-        $k = explode('-', $k, 2);
-        if (count($k) === 2 && $k[0] === 'src') {
-            $source = base64_decode($k[1]);
-        }
-    }
-}
-if ($source !== null) {
-    if ($as !== null) {
-        $as->setPreviousSource($source);
-    }
-    \SimpleSAML\Module\multiauth\Auth\Source\MultiAuth::delegateAuthentication($source, $state);
-}
-
-if (array_key_exists('multiauth:preselect', $state)) {
-    $source = $state['multiauth:preselect'];
-    \SimpleSAML\Module\multiauth\Auth\Source\MultiAuth::delegateAuthentication($source, $state);
-}
-
-$globalConfig = \SimpleSAML\Configuration::getInstance();
-$t = new \SimpleSAML\XHTML\Template($globalConfig, 'multiauth:selectsource.twig');
-
-$defaultLanguage = $globalConfig->getString('language.default', 'en');
-$language = $t->getTranslator()->getLanguage()->getLanguage();
-
-$sources = $state[\SimpleSAML\Module\multiauth\Auth\Source\MultiAuth::SOURCESID];
-foreach ($sources as $key => $source) {
-    $sources[$key]['source64'] = base64_encode($sources[$key]['source']);
-    if (isset($sources[$key]['text'][$language])) {
-        $sources[$key]['text'] = $sources[$key]['text'][$language];
-    } else {
-        $sources[$key]['text'] = $sources[$key]['text'][$defaultLanguage];
-    }
-
-    if (isset($sources[$key]['help'][$language])) {
-        $sources[$key]['help'] = $sources[$key]['help'][$language];
-    } else {
-        $sources[$key]['help'] = $sources[$key]['help'][$defaultLanguage];
-    }
-}
-
-$t->data['authstate'] = $authStateId;
-$t->data['sources'] = $sources;
-$t->data['selfUrl'] = $_SERVER['PHP_SELF'];
-
-if ($as !== null) {
-    $t->data['preferred'] = $as->getPreviousSource();
-} else {
-    $t->data['preferred'] = null;
-}
-$t->send();
-exit();
+$controller = new Controller\DiscoController($config, $session);
+$response = $controller->discovery($request);
+$response->send();
diff --git a/tests/modules/multiauth/lib/Controller/DiscoControllerTest.php b/tests/modules/multiauth/lib/Controller/DiscoControllerTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..e6b082dc60355ae94c854d1ad0de01a2c5650bfe
--- /dev/null
+++ b/tests/modules/multiauth/lib/Controller/DiscoControllerTest.php
@@ -0,0 +1,366 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\multiauth\Controller;
+
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Auth\Source;
+use SimpleSAML\Auth\State;
+use SimpleSAML\Configuration;
+use SimpleSAML\Error;
+use SimpleSAML\HTTP\RunnableResponse;
+use SimpleSAML\Module\multiauth\Auth\Source\MultiAuth;
+use SimpleSAML\Module\multiauth\Controller;
+use SimpleSAML\Session;
+use SimpleSAML\XHTML\Template;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Set of tests for the controllers in the "multiauth" module.
+ *
+ * @package SimpleSAML\Test
+ */
+class DiscoControllerTest extends TestCase
+{
+    /** @var \SimpleSAML\Configuration */
+    protected $config;
+
+    /** @var \SimpleSAML\Session */
+    protected $session;
+
+
+    /**
+     * Set up for each test.
+     * @return void
+     */
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->config = Configuration::loadFromArray(
+            [
+                'module.enable' => ['multiauth' => true],
+            ],
+            '[ARRAY]',
+            'simplesaml'
+        );
+
+        $this->session = Session::getSessionFromRequest();
+
+        Configuration::setPreLoadedConfig(
+            Configuration::loadFromArray(
+                [
+                    'admin' => ['core:AdminPassword'],
+                    'admin2' => ['core:AdminPassword'],
+                    'multi' => [
+                        'multiauth:MultiAuth',
+                        'sources' => [
+                            'admin',
+                            'admin2'
+                        ]
+                    ],
+                ],
+                '[ARRAY]',
+                'simplesaml'
+            ),
+            'authsources.php',
+            'simplesaml'
+        );
+    }
+
+
+    /**
+     * Test that a missing AuthState results in a BadRequest-error
+     * @return void
+     * @throws \SimpleSAML\Error\BadRequest
+     */
+    public function testDiscoveryMissingState(): void
+    {
+        $request = Request::create(
+            '/discovery',
+            'GET'
+        );
+
+        $c = new Controller\DiscoController($this->config, $this->session);
+
+        $this->expectException(Error\BadRequest::class);
+        $this->expectExceptionMessage('Missing AuthState parameter.');
+
+        $c->discovery($request);
+    }
+
+
+    /**
+     * Test that a valid requests results in a Twig template
+     * @return void
+     */
+    public function testDiscoveryFallthru(): void
+    {
+        $request = Request::create(
+            '/discovery',
+            'GET',
+            ['AuthState' => 'someState']
+        );
+
+        $c = new Controller\DiscoController($this->config, $this->session);
+
+        $c->setAuthState(new class () extends State {
+            public static function loadState(string $id, string $stage, bool $allowMissing = false): ?array
+            {
+                return [
+                    'LogoutState' => [
+                        'multiauth:discovery' => 'foo'
+                    ],
+                    MultiAuth::SOURCESID => [
+                        'source1' => ['source' => 'admin', 'help' => ['en' => 'help'], 'text' => ['en' => 'text']],
+                        'source2' => ['source' => 'test', 'help' => ['en' => 'help'], 'text' => ['en' => 'text']]
+                    ]
+                ];
+            }
+        });
+
+        $c->setAuthSource(new class () extends MultiAuth {
+            public function __construct()
+            {
+                // stub
+            }
+
+            public function authenticate(array &$state): void
+            {
+                // stub
+            }
+
+            public static function getById(string $authId, ?string $type = null): ?Source
+            {
+                return new static();
+            }
+        });
+
+        $response = $c->discovery($request);
+
+        $this->assertInstanceOf(Template::class, $response);
+        $this->assertTrue($response->isSuccessful());
+    }
+
+
+    /**
+     * Test that a valid requests results in a Twig template
+     * @return void
+     */
+    public function testDiscoveryFallthruWithSource(): void
+    {
+        $request = Request::create(
+            '/discovery',
+            'GET',
+            ['AuthState' => 'someState']
+        );
+
+        $c = new Controller\DiscoController($this->config, $this->session);
+
+        $c->setAuthState(new class () extends State {
+            public static function loadState(string $id, string $stage, bool $allowMissing = false): ?array
+            {
+                return [
+                    'LogoutState' => [
+                        'multiauth:discovery' => 'foo'
+                    ],
+                    '\SimpleSAML\Auth\Source.id' => 'multi',
+                    MultiAuth::SOURCESID => [
+                        'source1' => ['source' => 'admin', 'help' => ['en' => 'help'], 'text' => ['en' => 'text']],
+                        'source2' => ['source' => 'test', 'help' => ['en' => 'help'], 'text' => ['en' => 'text']]
+                    ]
+                ];
+            }
+        });
+
+        $c->setAuthSource(new class () extends MultiAuth {
+            public function __construct()
+            {
+                // stub
+            }
+
+            public function authenticate(array &$state): void
+            {
+                // stub
+            }
+
+            public static function getById(string $authId, ?string $type = null): ?Source
+            {
+                return new static();
+            }
+        });
+
+        $response = $c->discovery($request);
+
+        $this->assertInstanceOf(Template::class, $response);
+        $this->assertTrue($response->isSuccessful());
+    }
+
+
+    /**
+     * Test that a valid requests results in a Twig template
+     * @return void
+     */
+    public function testDiscoveryDelegateAuth1(): void
+    {
+        $request = Request::create(
+            '/discovery',
+            'GET',
+            ['AuthState' => 'someState']
+        );
+
+        $c = new Controller\DiscoController($this->config, $this->session);
+
+        $c->setAuthState(new class () extends State {
+            public static function loadState(string $id, string $stage, bool $allowMissing = false): ?array
+            {
+                return [
+                    'LogoutState' => [
+                        'multiauth:discovery' => 'foo'
+                    ],
+                    'multiauth:preselect' => 'admin',
+                    '\SimpleSAML\Auth\Source.id' => 'multi',
+                    MultiAuth::AUTHID => 'bar',
+                    MultiAuth::SOURCESID => [
+                        'source1' => ['source' => 'admin', 'help' => ['en' => 'help'], 'text' => ['nl' => 'text']],
+                        'source2' => ['source' => 'test', 'text' => ['en' => 'text'], 'help' => ['nl' => 'help']]
+                    ]
+                ];
+            }
+        });
+
+        $c->setAuthSource(new class () extends MultiAuth {
+            public function __construct()
+            {
+                // stub
+            }
+
+            public function authenticate(array &$state): void
+            {
+                // stub
+            }
+
+            public static function getById(string $authId, ?string $type = null): ?Source
+            {
+                return new static();
+            }
+        });
+
+        $response = $c->discovery($request);
+
+        $this->assertInstanceOf(RunnableResponse::class, $response);
+        $this->assertTrue($response->isSuccessful());
+    }
+
+
+    /**
+     * Test that a valid requests results in a Twig template
+     * @return void
+     */
+    public function testDiscoveryDelegateAuth1WithPreviousSource(): void
+    {
+        $request = Request::create(
+            '/discovery',
+            'GET',
+            ['AuthState' => 'someState', 'source' => 'admin']
+        );
+
+        $c = new Controller\DiscoController($this->config, $this->session);
+
+        $c->setAuthState(new class () extends State {
+            public static function loadState(string $id, string $stage, bool $allowMissing = false): ?array
+            {
+                return [
+                    'LogoutState' => [
+                        'multiauth:discovery' => 'foo'
+                    ],
+                    'multiauth:preselect' => 'admin',
+                    '\SimpleSAML\Auth\Source.id' => 'multi',
+                    MultiAuth::AUTHID => 'bar',
+                    MultiAuth::SOURCESID => [
+                        'source1' => ['source' => 'admin', 'help' => ['en' => 'help']],
+                        'source2' => ['source' => 'test', 'text' => ['en' => 'text']]
+                    ]
+                ];
+            }
+        });
+
+        $c->setAuthSource(new class () extends MultiAuth {
+            public function __construct()
+            {
+                // stub
+            }
+
+            public function authenticate(array &$state): void
+            {
+                // stub
+            }
+
+            public static function getById(string $authId, ?string $type = null): ?Source
+            {
+                return new static();
+            }
+        });
+
+        $response = $c->discovery($request);
+
+        $this->assertInstanceOf(RunnableResponse::class, $response);
+        $this->assertTrue($response->isSuccessful());
+    }
+
+
+    /**
+     * Test that a valid requests results in a RunnableResponse
+     * @return void
+     */
+    public function testDiscoveryDelegateAuth2(): void
+    {
+        $request = Request::create(
+            '/discovery',
+            'GET',
+            ['AuthState' => 'someState', 'src-YWRtaW4=' => 'admin']
+        );
+
+        $c = new Controller\DiscoController($this->config, $this->session);
+
+        $c->setAuthState(new class () extends State {
+            public static function loadState(string $id, string $stage, bool $allowMissing = false): ?array
+            {
+                return [
+                    'LogoutState' => [
+                        'multiauth:discovery' => 'foo'
+                    ],
+                    MultiAuth::AUTHID => 'bar',
+                    MultiAuth::SOURCESID => [
+                        'source1' => ['source' => 'admin', 'help' => ['en' => 'help'], 'text' => ['en' => 'text']],
+                        'source2' => ['source' => 'test', 'text' => ['en' => 'text'], 'help' => ['en' => 'help']]
+                    ]
+                ];
+            }
+        });
+
+        $c->setAuthSource(new class () extends MultiAuth {
+            public function __construct()
+            {
+                // stub
+            }
+
+            public function authenticate(array &$state): void
+            {
+                // stub
+            }
+
+            public static function getById(string $authId, ?string $type = null): ?Source
+            {
+                return new static();
+            }
+        });
+
+        $response = $c->discovery($request);
+
+        $this->assertInstanceOf(Response::class, $response);
+        $this->assertTrue($response->isSuccessful());
+    }
+}