diff --git a/modules/exampleauth/lib/Controller/ExampleAuth.php b/modules/exampleauth/lib/Controller/ExampleAuth.php
new file mode 100644
index 0000000000000000000000000000000000000000..5765622c52539d3036639b0911e582c46d5d0a80
--- /dev/null
+++ b/modules/exampleauth/lib/Controller/ExampleAuth.php
@@ -0,0 +1,194 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\exampleauth\Controller;
+
+use SimpleSAML\Auth;
+use SimpleSAML\Configuration;
+use SimpleSAML\Error;
+use SimpleSAML\HTTP\RunnableResponse;
+use SimpleSAML\Module\exampleauth\Auth\Source\External;
+use SimpleSAML\Session;
+use SimpleSAML\Utils;
+use SimpleSAML\XHTML\Template;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Session\Session as SymfonySession;
+
+use function array_key_exists;
+use function preg_match;
+use function session_id;
+use function session_start;
+use function urldecode;
+
+/**
+ * Controller class for the exampleauth module.
+ *
+ * This class serves the different views available in the module.
+ *
+ * @package simplesamlphp/simplesamlphp
+ */
+class ExampleAuth
+{
+    /** @var \SimpleSAML\Configuration */
+    protected Configuration $config;
+
+    /** @var \SimpleSAML\Session */
+    protected Session $session;
+
+
+    /**
+     * Controller constructor.
+     *
+     * It initializes the global configuration and session 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;
+    }
+
+
+    /**
+     * Auth testpage.
+     *
+     * @param \Symfony\Component\HttpFoundation\Request $request The current request.
+     *
+     * @return \SimpleSAML\XHTML\Template
+     */
+    public function authpage(Request $request): Template
+    {
+        /**
+         * This page serves as a dummy login page.
+         *
+         * Note that we don't actually validate the user in this example. This page
+         * just serves to make the example work out of the box.
+         */
+        if (!$request->query->has('ReturnTo')) {
+            die('Missing ReturnTo parameter.');
+        }
+
+        $httpUtils = new Utils\HTTP();
+        $returnTo = $httpUtils->checkURLAllowed($request->get('ReturnTo'));
+
+        /**
+         * The following piece of code would never be found in a real authentication page. Its
+         * purpose in this example is to make this example safer in the case where the
+         * administrator of the IdP leaves the exampleauth-module enabled in a production
+         * environment.
+         *
+         * What we do here is to extract the $state-array identifier, and check that it belongs to
+         * the exampleauth:External process.
+         */
+        if (!preg_match('@State=(.*)@', $returnTo, $matches)) {
+            die('Invalid ReturnTo URL for this example.');
+        }
+
+        /**
+         * The loadState-function will not return if the second parameter does not
+         * match the parameter passed to saveState, so by now we know that we arrived here
+         * through the exampleauth:External authentication page.
+         */
+        Auth\State::loadState(urldecode($matches[1]), 'exampleauth:External');
+
+        // our list of users.
+        $users = [
+            'student' => [
+                'password' => 'student',
+                'uid' => 'student',
+                'name' => 'Student Name',
+                'mail' => 'somestudent@example.org',
+                'type' => 'student',
+            ],
+            'admin' => [
+                'password' => 'admin',
+                'uid' => 'admin',
+                'name' => 'Admin Name',
+                'mail' => 'someadmin@example.org',
+                'type' => 'employee',
+            ],
+        ];
+
+        // time to handle login responses; since this is a dummy example, we accept any data
+        $badUserPass = false;
+        if ($request->getMethod() === 'POST') {
+            $username = $request->request->get('username');
+            $password = $request->request->get('password');
+
+            if (!isset($users[$username]) || $users[$username]['password'] !== $password) {
+                $badUserPass = true;
+            } else {
+                $user = $users[$username];
+
+                $session = new SymfonySession();
+                if (!$session->getId()) {
+                    $session->start();
+                }
+
+                $session->set('uid', $user['uid']);
+                $session->set('name', $user['name']);
+                $session->set('mail', $user['mail']);
+                $session->set('type', $user['type']);
+
+                $httpUtils->redirectTrustedURL($returnTo);
+            }
+        }
+
+        // if we get this far, we need to show the login page to the user
+        $t = new Template($this->config, 'exampleauth:authenticate.twig');
+        $t->data['badUserPass'] = $badUserPass;
+        $t->data['returnTo'] = $returnTo;
+        $t->send();
+    }
+
+
+    /**
+     * Redirect testpage.
+     *
+     * @param \Symfony\Component\HttpFoundation\Request $request The current request.
+     *
+     * @return \SimpleSAML\HTTP\RunnableResponse
+     */
+    public function redirect(Request $request): RunnableResponse
+    {
+        /**
+         * Request handler for redirect filter test.
+         */
+        if (!$request->has('StateId')) {
+            throw new Error\BadRequest('Missing required StateId query parameter.');
+        }
+
+        /** @var array $state */
+        $state = Auth\State::loadState($request->get('StateId'), 'exampleauth:redirectfilter-test');
+        $state['Attributes']['RedirectTest2'] = ['OK'];
+
+        return new RunnableResponse([Auth\ProcessingChain::class, 'resumeProcessing'], [$state]);
+    }
+
+
+    /**
+     * Resume testpage.
+     *
+     * @param \Symfony\Component\HttpFoundation\Request $request The current request.
+     *
+     * @return \SimpleSAML\HTTP\RunnableResponse
+     */
+    public function resume(Request $request): RunnableResponse
+    {
+        /**
+         * This page serves as the point where the user's authentication
+         * process is resumed after the login page.
+         *
+         * It simply passes control back to the class.
+         */
+
+        return new RunnableResponse([External::class, 'resume'], []);
+    }
+}
diff --git a/modules/exampleauth/routing/routes/routes.yml b/modules/exampleauth/routing/routes/routes.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d36c2be2e95be365fe846d4980533b4fce9e6f5e
--- /dev/null
+++ b/modules/exampleauth/routing/routes/routes.yml
@@ -0,0 +1,9 @@
+exampleauth-authpage:
+    path:       /authpage
+    defaults:   { _controller: 'SimpleSAML\Module\exampleauth\Controller\Autotest::authpage' }
+exampleauth-redirect:
+    path:       /redirect
+    defaults:   { _controller: 'SimpleSAML\Module\exampleauth\Controller\Autotest::redirect' }
+exampleauth-resume:
+    path:       /resume
+    defaults:   { _controller: 'SimpleSAML\Module\exampleauth\Controller\Autotest::resume' }