From 44507da91242e2247d168de93aa38ea6b0608315 Mon Sep 17 00:00:00 2001
From: Tim van Dijen <tvdijen@gmail.com>
Date: Wed, 4 May 2022 15:22:25 +0200
Subject: [PATCH] Add controllers for the saml-module (#1623)

Add some controllers + tests
---
 composer.lock                                 | 319 +++++----
 docs/simplesamlphp-artifact-idp.md            |   4 +-
 docs/simplesamlphp-hok-idp.md                 |   4 +-
 docs/simplesamlphp-idp.md                     |   4 +-
 lib/SimpleSAML/Configuration.php              |   8 +-
 lib/SimpleSAML/Database.php                   |  10 +-
 .../Logger/SyslogLoggingHandler.php           |   5 +-
 lib/SimpleSAML/XHTML/IdPDisco.php             |  22 +-
 metadata-templates/saml20-sp-remote.php       |   4 +-
 modules/admin/lib/Controller/Federation.php   |   5 +-
 modules/core/docs/authproc_attributelimit.md  |   8 +-
 .../core/lib/Auth/Process/AttributeAlter.php  |   2 +-
 modules/core/lib/Controller/Logout.php        |   3 +-
 modules/saml/lib/Auth/Source/SP.php           |  19 +-
 modules/saml/lib/Controller/Disco.php         |  49 ++
 modules/saml/lib/Controller/Proxy.php         | 122 ++++
 .../saml/lib/Controller/ServiceProvider.php   | 613 ++++++++++++++++++
 modules/saml/lib/IdP/SAML2.php                |   5 +-
 modules/saml/routing/routes/routes.yaml       |  30 +
 modules/saml/www/disco.php                    |   8 -
 modules/saml/www/proxy/invalid_session.php    |  56 --
 modules/saml/www/sp/discoresp.php             |  33 -
 modules/saml/www/sp/metadata.php              |  51 --
 modules/saml/www/sp/saml2-acs.php             | 280 --------
 modules/saml/www/sp/saml2-logout.php          | 160 -----
 .../www/sp/wrong_authncontextclassref.php     |   8 -
 phpcs.xml                                     |   7 +-
 psalm.xml                                     |   1 -
 tests/modules/saml/lib/Auth/Source/SPTest.php |   7 +-
 .../modules/saml/lib/Controller/DiscoTest.php |  67 ++
 .../modules/saml/lib/Controller/ProxyTest.php | 165 +++++
 .../lib/Controller/ServiceProviderTest.php    | 438 +++++++++++++
 32 files changed, 1721 insertions(+), 796 deletions(-)
 create mode 100644 modules/saml/lib/Controller/Disco.php
 create mode 100644 modules/saml/lib/Controller/Proxy.php
 create mode 100644 modules/saml/lib/Controller/ServiceProvider.php
 create mode 100644 modules/saml/routing/routes/routes.yaml
 delete mode 100644 modules/saml/www/disco.php
 delete mode 100644 modules/saml/www/proxy/invalid_session.php
 delete mode 100644 modules/saml/www/sp/discoresp.php
 delete mode 100644 modules/saml/www/sp/metadata.php
 delete mode 100644 modules/saml/www/sp/saml2-acs.php
 delete mode 100644 modules/saml/www/sp/saml2-logout.php
 delete mode 100644 modules/saml/www/sp/wrong_authncontextclassref.php
 create mode 100644 tests/modules/saml/lib/Controller/DiscoTest.php
 create mode 100644 tests/modules/saml/lib/Controller/ProxyTest.php
 create mode 100644 tests/modules/saml/lib/Controller/ServiceProviderTest.php

diff --git a/composer.lock b/composer.lock
index 437d7dec1..d19f13809 100644
--- a/composer.lock
+++ b/composer.lock
@@ -601,16 +601,16 @@
         },
         {
             "name": "simplesamlphp/saml2",
-            "version": "v4.5.1",
+            "version": "v4.6.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/simplesamlphp/saml2.git",
-                "reference": "88586eb071476a74cd92d50cb789ad45c0b62f1e"
+                "reference": "46d93daa9a96a1d896fe35cb75897a91e1795442"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/simplesamlphp/saml2/zipball/88586eb071476a74cd92d50cb789ad45c0b62f1e",
-                "reference": "88586eb071476a74cd92d50cb789ad45c0b62f1e",
+                "url": "https://api.github.com/repos/simplesamlphp/saml2/zipball/46d93daa9a96a1d896fe35cb75897a91e1795442",
+                "reference": "46d93daa9a96a1d896fe35cb75897a91e1795442",
                 "shasum": ""
             },
             "require": {
@@ -653,22 +653,22 @@
             "description": "SAML2 PHP library from SimpleSAMLphp",
             "support": {
                 "issues": "https://github.com/simplesamlphp/saml2/issues",
-                "source": "https://github.com/simplesamlphp/saml2/tree/v4.5.1"
+                "source": "https://github.com/simplesamlphp/saml2/tree/v4.6.0"
             },
-            "time": "2022-03-22T12:02:23+00:00"
+            "time": "2022-04-08T13:47:36+00:00"
         },
         {
             "name": "symfony/cache",
-            "version": "v5.4.6",
+            "version": "v5.4.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/cache.git",
-                "reference": "c0718d0e01ac14251a45cc9c8b93716ec41ae64b"
+                "reference": "ba06841ed293fcaf79a592f59fdaba471f7c756c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/cache/zipball/c0718d0e01ac14251a45cc9c8b93716ec41ae64b",
-                "reference": "c0718d0e01ac14251a45cc9c8b93716ec41ae64b",
+                "url": "https://api.github.com/repos/symfony/cache/zipball/ba06841ed293fcaf79a592f59fdaba471f7c756c",
+                "reference": "ba06841ed293fcaf79a592f59fdaba471f7c756c",
                 "shasum": ""
             },
             "require": {
@@ -736,7 +736,7 @@
                 "psr6"
             ],
             "support": {
-                "source": "https://github.com/symfony/cache/tree/v5.4.6"
+                "source": "https://github.com/symfony/cache/tree/v5.4.7"
             },
             "funding": [
                 {
@@ -752,20 +752,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-03-02T12:56:28+00:00"
+            "time": "2022-03-22T15:31:03+00:00"
         },
         {
             "name": "symfony/cache-contracts",
-            "version": "v2.5.0",
+            "version": "v2.5.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/cache-contracts.git",
-                "reference": "ac2e168102a2e06a2624f0379bde94cd5854ced2"
+                "reference": "64be4a7acb83b6f2bf6de9a02cee6dad41277ebc"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/ac2e168102a2e06a2624f0379bde94cd5854ced2",
-                "reference": "ac2e168102a2e06a2624f0379bde94cd5854ced2",
+                "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/64be4a7acb83b6f2bf6de9a02cee6dad41277ebc",
+                "reference": "64be4a7acb83b6f2bf6de9a02cee6dad41277ebc",
                 "shasum": ""
             },
             "require": {
@@ -815,7 +815,7 @@
                 "standards"
             ],
             "support": {
-                "source": "https://github.com/symfony/cache-contracts/tree/v2.5.0"
+                "source": "https://github.com/symfony/cache-contracts/tree/v2.5.1"
             },
             "funding": [
                 {
@@ -831,20 +831,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-08-17T14:20:01+00:00"
+            "time": "2022-01-02T09:53:40+00:00"
         },
         {
             "name": "symfony/config",
-            "version": "v5.4.3",
+            "version": "v5.4.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/config.git",
-                "reference": "d65e1bd990c740e31feb07d2b0927b8d4df9956f"
+                "reference": "05624c386afa1b4ccc1357463d830fade8d9d404"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/config/zipball/d65e1bd990c740e31feb07d2b0927b8d4df9956f",
-                "reference": "d65e1bd990c740e31feb07d2b0927b8d4df9956f",
+                "url": "https://api.github.com/repos/symfony/config/zipball/05624c386afa1b4ccc1357463d830fade8d9d404",
+                "reference": "05624c386afa1b4ccc1357463d830fade8d9d404",
                 "shasum": ""
             },
             "require": {
@@ -894,7 +894,7 @@
             "description": "Helps you find, load, combine, autofill and validate configuration values of any kind",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/config/tree/v5.4.3"
+                "source": "https://github.com/symfony/config/tree/v5.4.7"
             },
             "funding": [
                 {
@@ -910,20 +910,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-01-03T09:50:52+00:00"
+            "time": "2022-03-21T13:42:03+00:00"
         },
         {
             "name": "symfony/console",
-            "version": "v5.4.5",
+            "version": "v5.4.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/console.git",
-                "reference": "d8111acc99876953f52fe16d4c50eb60940d49ad"
+                "reference": "900275254f0a1a2afff1ab0e11abd5587a10e1d6"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/console/zipball/d8111acc99876953f52fe16d4c50eb60940d49ad",
-                "reference": "d8111acc99876953f52fe16d4c50eb60940d49ad",
+                "url": "https://api.github.com/repos/symfony/console/zipball/900275254f0a1a2afff1ab0e11abd5587a10e1d6",
+                "reference": "900275254f0a1a2afff1ab0e11abd5587a10e1d6",
                 "shasum": ""
             },
             "require": {
@@ -993,7 +993,7 @@
                 "terminal"
             ],
             "support": {
-                "source": "https://github.com/symfony/console/tree/v5.4.5"
+                "source": "https://github.com/symfony/console/tree/v5.4.7"
             },
             "funding": [
                 {
@@ -1009,20 +1009,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-02-24T12:45:35+00:00"
+            "time": "2022-03-31T17:09:19+00:00"
         },
         {
             "name": "symfony/dependency-injection",
-            "version": "v5.4.6",
+            "version": "v5.4.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/dependency-injection.git",
-                "reference": "0828fa3e6e436243dbb3dc85abe6b698b3876b89"
+                "reference": "35588b2afb08ea3a142d62fefdcad4cb09be06ed"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/0828fa3e6e436243dbb3dc85abe6b698b3876b89",
-                "reference": "0828fa3e6e436243dbb3dc85abe6b698b3876b89",
+                "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/35588b2afb08ea3a142d62fefdcad4cb09be06ed",
+                "reference": "35588b2afb08ea3a142d62fefdcad4cb09be06ed",
                 "shasum": ""
             },
             "require": {
@@ -1082,7 +1082,7 @@
             "description": "Allows you to standardize and centralize the way objects are constructed in your application",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/dependency-injection/tree/v5.4.6"
+                "source": "https://github.com/symfony/dependency-injection/tree/v5.4.7"
             },
             "funding": [
                 {
@@ -1098,20 +1098,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-03-02T12:42:23+00:00"
+            "time": "2022-03-08T15:43:06+00:00"
         },
         {
             "name": "symfony/deprecation-contracts",
-            "version": "v2.5.0",
+            "version": "v2.5.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/deprecation-contracts.git",
-                "reference": "6f981ee24cf69ee7ce9736146d1c57c2780598a8"
+                "reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/6f981ee24cf69ee7ce9736146d1c57c2780598a8",
-                "reference": "6f981ee24cf69ee7ce9736146d1c57c2780598a8",
+                "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/e8b495ea28c1d97b5e0c121748d6f9b53d075c66",
+                "reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66",
                 "shasum": ""
             },
             "require": {
@@ -1149,7 +1149,7 @@
             "description": "A generic function and convention to trigger deprecation notices",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.0"
+                "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.1"
             },
             "funding": [
                 {
@@ -1165,20 +1165,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-07-12T14:48:14+00:00"
+            "time": "2022-01-02T09:53:40+00:00"
         },
         {
             "name": "symfony/error-handler",
-            "version": "v5.4.3",
+            "version": "v5.4.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/error-handler.git",
-                "reference": "c4ffc2cd919950d13c8c9ce32a70c70214c3ffc5"
+                "reference": "060bc01856a1846e3e4385261bc9ed11a1dd7b6a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/error-handler/zipball/c4ffc2cd919950d13c8c9ce32a70c70214c3ffc5",
-                "reference": "c4ffc2cd919950d13c8c9ce32a70c70214c3ffc5",
+                "url": "https://api.github.com/repos/symfony/error-handler/zipball/060bc01856a1846e3e4385261bc9ed11a1dd7b6a",
+                "reference": "060bc01856a1846e3e4385261bc9ed11a1dd7b6a",
                 "shasum": ""
             },
             "require": {
@@ -1220,7 +1220,7 @@
             "description": "Provides tools to manage errors and ease debugging PHP code",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/error-handler/tree/v5.4.3"
+                "source": "https://github.com/symfony/error-handler/tree/v5.4.7"
             },
             "funding": [
                 {
@@ -1236,7 +1236,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-01-02T09:53:40+00:00"
+            "time": "2022-03-18T16:21:29+00:00"
         },
         {
             "name": "symfony/event-dispatcher",
@@ -1325,16 +1325,16 @@
         },
         {
             "name": "symfony/event-dispatcher-contracts",
-            "version": "v2.5.0",
+            "version": "v2.5.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/event-dispatcher-contracts.git",
-                "reference": "66bea3b09be61613cd3b4043a65a8ec48cfa6d2a"
+                "reference": "f98b54df6ad059855739db6fcbc2d36995283fe1"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/66bea3b09be61613cd3b4043a65a8ec48cfa6d2a",
-                "reference": "66bea3b09be61613cd3b4043a65a8ec48cfa6d2a",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/f98b54df6ad059855739db6fcbc2d36995283fe1",
+                "reference": "f98b54df6ad059855739db6fcbc2d36995283fe1",
                 "shasum": ""
             },
             "require": {
@@ -1384,7 +1384,7 @@
                 "standards"
             ],
             "support": {
-                "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v2.5.0"
+                "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v2.5.1"
             },
             "funding": [
                 {
@@ -1400,20 +1400,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-07-12T14:48:14+00:00"
+            "time": "2022-01-02T09:53:40+00:00"
         },
         {
             "name": "symfony/filesystem",
-            "version": "v5.4.6",
+            "version": "v5.4.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/filesystem.git",
-                "reference": "d53a45039974952af7f7ebc461ccdd4295e29440"
+                "reference": "3a4442138d80c9f7b600fb297534ac718b61d37f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/filesystem/zipball/d53a45039974952af7f7ebc461ccdd4295e29440",
-                "reference": "d53a45039974952af7f7ebc461ccdd4295e29440",
+                "url": "https://api.github.com/repos/symfony/filesystem/zipball/3a4442138d80c9f7b600fb297534ac718b61d37f",
+                "reference": "3a4442138d80c9f7b600fb297534ac718b61d37f",
                 "shasum": ""
             },
             "require": {
@@ -1448,7 +1448,7 @@
             "description": "Provides basic utilities for the filesystem",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/filesystem/tree/v5.4.6"
+                "source": "https://github.com/symfony/filesystem/tree/v5.4.7"
             },
             "funding": [
                 {
@@ -1464,7 +1464,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-03-02T12:42:23+00:00"
+            "time": "2022-04-01T12:33:59+00:00"
         },
         {
             "name": "symfony/finder",
@@ -1531,16 +1531,16 @@
         },
         {
             "name": "symfony/framework-bundle",
-            "version": "v5.4.6",
+            "version": "v5.4.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/framework-bundle.git",
-                "reference": "76ea755f30924924ea37a28e098df61679efcb63"
+                "reference": "7520f553c7a7721652c1b7ac95c09dae62a1676e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/76ea755f30924924ea37a28e098df61679efcb63",
-                "reference": "76ea755f30924924ea37a28e098df61679efcb63",
+                "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/7520f553c7a7721652c1b7ac95c09dae62a1676e",
+                "reference": "7520f553c7a7721652c1b7ac95c09dae62a1676e",
                 "shasum": ""
             },
             "require": {
@@ -1610,7 +1610,6 @@
                 "symfony/messenger": "^5.4|^6.0",
                 "symfony/mime": "^4.4|^5.0|^6.0",
                 "symfony/notifier": "^5.4|^6.0",
-                "symfony/phpunit-bridge": "^5.3|^6.0",
                 "symfony/polyfill-intl-icu": "~1.0",
                 "symfony/process": "^4.4|^5.0|^6.0",
                 "symfony/property-info": "^4.4|^5.0|^6.0",
@@ -1663,7 +1662,7 @@
             "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/framework-bundle/tree/v5.4.6"
+                "source": "https://github.com/symfony/framework-bundle/tree/v5.4.7"
             },
             "funding": [
                 {
@@ -1679,7 +1678,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-03-04T14:13:35+00:00"
+            "time": "2022-04-01T06:09:41+00:00"
         },
         {
             "name": "symfony/http-foundation",
@@ -1756,16 +1755,16 @@
         },
         {
             "name": "symfony/http-kernel",
-            "version": "v5.4.6",
+            "version": "v5.4.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/http-kernel.git",
-                "reference": "d41f29ae9af1b5f40c7ebcddf09082953229411d"
+                "reference": "509243b9b3656db966284c45dffce9316c1ecc5c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/http-kernel/zipball/d41f29ae9af1b5f40c7ebcddf09082953229411d",
-                "reference": "d41f29ae9af1b5f40c7ebcddf09082953229411d",
+                "url": "https://api.github.com/repos/symfony/http-kernel/zipball/509243b9b3656db966284c45dffce9316c1ecc5c",
+                "reference": "509243b9b3656db966284c45dffce9316c1ecc5c",
                 "shasum": ""
             },
             "require": {
@@ -1848,7 +1847,7 @@
             "description": "Provides a structured process for converting a Request into a Response",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/http-kernel/tree/v5.4.6"
+                "source": "https://github.com/symfony/http-kernel/tree/v5.4.7"
             },
             "funding": [
                 {
@@ -1864,7 +1863,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-03-05T21:14:51+00:00"
+            "time": "2022-04-02T06:04:20+00:00"
         },
         {
             "name": "symfony/intl",
@@ -2617,22 +2616,22 @@
         },
         {
             "name": "symfony/service-contracts",
-            "version": "v2.5.0",
+            "version": "v2.5.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/service-contracts.git",
-                "reference": "1ab11b933cd6bc5464b08e81e2c5b07dec58b0fc"
+                "reference": "24d9dc654b83e91aa59f9d167b131bc3b5bea24c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/service-contracts/zipball/1ab11b933cd6bc5464b08e81e2c5b07dec58b0fc",
-                "reference": "1ab11b933cd6bc5464b08e81e2c5b07dec58b0fc",
+                "url": "https://api.github.com/repos/symfony/service-contracts/zipball/24d9dc654b83e91aa59f9d167b131bc3b5bea24c",
+                "reference": "24d9dc654b83e91aa59f9d167b131bc3b5bea24c",
                 "shasum": ""
             },
             "require": {
                 "php": ">=7.2.5",
                 "psr/container": "^1.1",
-                "symfony/deprecation-contracts": "^2.1"
+                "symfony/deprecation-contracts": "^2.1|^3"
             },
             "conflict": {
                 "ext-psr": "<1.1|>=2"
@@ -2680,7 +2679,7 @@
                 "standards"
             ],
             "support": {
-                "source": "https://github.com/symfony/service-contracts/tree/v2.5.0"
+                "source": "https://github.com/symfony/service-contracts/tree/v2.5.1"
             },
             "funding": [
                 {
@@ -2696,7 +2695,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-11-04T16:48:04+00:00"
+            "time": "2022-03-13T20:07:29+00:00"
         },
         {
             "name": "symfony/string",
@@ -2786,16 +2785,16 @@
         },
         {
             "name": "symfony/translation-contracts",
-            "version": "v2.5.0",
+            "version": "v2.5.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/translation-contracts.git",
-                "reference": "d28150f0f44ce854e942b671fc2620a98aae1b1e"
+                "reference": "1211df0afa701e45a04253110e959d4af4ef0f07"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/d28150f0f44ce854e942b671fc2620a98aae1b1e",
-                "reference": "d28150f0f44ce854e942b671fc2620a98aae1b1e",
+                "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/1211df0afa701e45a04253110e959d4af4ef0f07",
+                "reference": "1211df0afa701e45a04253110e959d4af4ef0f07",
                 "shasum": ""
             },
             "require": {
@@ -2844,7 +2843,7 @@
                 "standards"
             ],
             "support": {
-                "source": "https://github.com/symfony/translation-contracts/tree/v2.5.0"
+                "source": "https://github.com/symfony/translation-contracts/tree/v2.5.1"
             },
             "funding": [
                 {
@@ -2860,20 +2859,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-08-17T14:20:01+00:00"
+            "time": "2022-01-02T09:53:40+00:00"
         },
         {
             "name": "symfony/twig-bridge",
-            "version": "v5.4.5",
+            "version": "v5.4.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/twig-bridge.git",
-                "reference": "648c8694a9470ae4aaf64cbce1b640f5941fd7c9"
+                "reference": "b43e9bdb57a39ffffb4c44a7ef0a47d338e9f1da"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/648c8694a9470ae4aaf64cbce1b640f5941fd7c9",
-                "reference": "648c8694a9470ae4aaf64cbce1b640f5941fd7c9",
+                "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/b43e9bdb57a39ffffb4c44a7ef0a47d338e9f1da",
+                "reference": "b43e9bdb57a39ffffb4c44a7ef0a47d338e9f1da",
                 "shasum": ""
             },
             "require": {
@@ -2965,7 +2964,7 @@
             "description": "Provides integration for Twig with various Symfony components",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/twig-bridge/tree/v5.4.5"
+                "source": "https://github.com/symfony/twig-bridge/tree/v5.4.7"
             },
             "funding": [
                 {
@@ -2981,7 +2980,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-02-09T08:59:58+00:00"
+            "time": "2022-04-01T06:09:41+00:00"
         },
         {
             "name": "symfony/var-dumper",
@@ -3074,16 +3073,16 @@
         },
         {
             "name": "symfony/var-exporter",
-            "version": "v5.4.6",
+            "version": "v5.4.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/var-exporter.git",
-                "reference": "49e2355fe6f59ea30c18ebb68edf13b7e20582e5"
+                "reference": "7eacaa588c9b27f2738575adb4a8457a80d9c807"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/var-exporter/zipball/49e2355fe6f59ea30c18ebb68edf13b7e20582e5",
-                "reference": "49e2355fe6f59ea30c18ebb68edf13b7e20582e5",
+                "url": "https://api.github.com/repos/symfony/var-exporter/zipball/7eacaa588c9b27f2738575adb4a8457a80d9c807",
+                "reference": "7eacaa588c9b27f2738575adb4a8457a80d9c807",
                 "shasum": ""
             },
             "require": {
@@ -3127,7 +3126,7 @@
                 "serialize"
             ],
             "support": {
-                "source": "https://github.com/symfony/var-exporter/tree/v5.4.6"
+                "source": "https://github.com/symfony/var-exporter/tree/v5.4.7"
             },
             "funding": [
                 {
@@ -3143,7 +3142,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-03-02T12:42:23+00:00"
+            "time": "2022-03-31T17:09:19+00:00"
         },
         {
             "name": "symfony/yaml",
@@ -3291,16 +3290,16 @@
         },
         {
             "name": "twig/twig",
-            "version": "v3.3.8",
+            "version": "v3.3.10",
             "source": {
                 "type": "git",
                 "url": "https://github.com/twigphp/Twig.git",
-                "reference": "972d8604a92b7054828b539f2febb0211dd5945c"
+                "reference": "8442df056c51b706793adf80a9fd363406dd3674"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/twigphp/Twig/zipball/972d8604a92b7054828b539f2febb0211dd5945c",
-                "reference": "972d8604a92b7054828b539f2febb0211dd5945c",
+                "url": "https://api.github.com/repos/twigphp/Twig/zipball/8442df056c51b706793adf80a9fd363406dd3674",
+                "reference": "8442df056c51b706793adf80a9fd363406dd3674",
                 "shasum": ""
             },
             "require": {
@@ -3351,7 +3350,7 @@
             ],
             "support": {
                 "issues": "https://github.com/twigphp/Twig/issues",
-                "source": "https://github.com/twigphp/Twig/tree/v3.3.8"
+                "source": "https://github.com/twigphp/Twig/tree/v3.3.10"
             },
             "funding": [
                 {
@@ -3363,7 +3362,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-02-04T06:59:48+00:00"
+            "time": "2022-04-06T06:47:41+00:00"
         },
         {
             "name": "webmozart/assert",
@@ -3737,16 +3736,16 @@
         },
         {
             "name": "composer/semver",
-            "version": "3.3.1",
+            "version": "3.3.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/composer/semver.git",
-                "reference": "5d8e574bb0e69188786b8ef77d43341222a41a71"
+                "reference": "3953f23262f2bff1919fc82183ad9acb13ff62c9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/composer/semver/zipball/5d8e574bb0e69188786b8ef77d43341222a41a71",
-                "reference": "5d8e574bb0e69188786b8ef77d43341222a41a71",
+                "url": "https://api.github.com/repos/composer/semver/zipball/3953f23262f2bff1919fc82183ad9acb13ff62c9",
+                "reference": "3953f23262f2bff1919fc82183ad9acb13ff62c9",
                 "shasum": ""
             },
             "require": {
@@ -3798,7 +3797,7 @@
             "support": {
                 "irc": "irc://irc.freenode.org/composer",
                 "issues": "https://github.com/composer/semver/issues",
-                "source": "https://github.com/composer/semver/tree/3.3.1"
+                "source": "https://github.com/composer/semver/tree/3.3.2"
             },
             "funding": [
                 {
@@ -3814,7 +3813,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-03-16T11:22:07+00:00"
+            "time": "2022-04-01T19:23:25+00:00"
         },
         {
             "name": "composer/xdebug-handler",
@@ -4036,16 +4035,16 @@
         },
         {
             "name": "felixfbecker/language-server-protocol",
-            "version": "1.5.1",
+            "version": "v1.5.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/felixfbecker/php-language-server-protocol.git",
-                "reference": "9d846d1f5cf101deee7a61c8ba7caa0a975cd730"
+                "reference": "6e82196ffd7c62f7794d778ca52b69feec9f2842"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/felixfbecker/php-language-server-protocol/zipball/9d846d1f5cf101deee7a61c8ba7caa0a975cd730",
-                "reference": "9d846d1f5cf101deee7a61c8ba7caa0a975cd730",
+                "url": "https://api.github.com/repos/felixfbecker/php-language-server-protocol/zipball/6e82196ffd7c62f7794d778ca52b69feec9f2842",
+                "reference": "6e82196ffd7c62f7794d778ca52b69feec9f2842",
                 "shasum": ""
             },
             "require": {
@@ -4086,9 +4085,9 @@
             ],
             "support": {
                 "issues": "https://github.com/felixfbecker/php-language-server-protocol/issues",
-                "source": "https://github.com/felixfbecker/php-language-server-protocol/tree/1.5.1"
+                "source": "https://github.com/felixfbecker/php-language-server-protocol/tree/v1.5.2"
             },
-            "time": "2021-02-22T14:02:09+00:00"
+            "time": "2022-03-02T22:36:06+00:00"
         },
         {
             "name": "mikey179/vfsstream",
@@ -4583,16 +4582,16 @@
         },
         {
             "name": "phpdocumentor/type-resolver",
-            "version": "1.6.0",
+            "version": "1.6.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpDocumentor/TypeResolver.git",
-                "reference": "93ebd0014cab80c4ea9f5e297ea48672f1b87706"
+                "reference": "77a32518733312af16a44300404e945338981de3"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/93ebd0014cab80c4ea9f5e297ea48672f1b87706",
-                "reference": "93ebd0014cab80c4ea9f5e297ea48672f1b87706",
+                "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/77a32518733312af16a44300404e945338981de3",
+                "reference": "77a32518733312af16a44300404e945338981de3",
                 "shasum": ""
             },
             "require": {
@@ -4627,9 +4626,9 @@
             "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
             "support": {
                 "issues": "https://github.com/phpDocumentor/TypeResolver/issues",
-                "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.6.0"
+                "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.6.1"
             },
-            "time": "2022-01-04T19:58:01+00:00"
+            "time": "2022-03-15T21:29:03+00:00"
         },
         {
             "name": "phpspec/prophecy",
@@ -5018,16 +5017,16 @@
         },
         {
             "name": "phpunit/phpunit",
-            "version": "9.5.19",
+            "version": "9.5.20",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
-                "reference": "35ea4b7f3acabb26f4bb640f8c30866c401da807"
+                "reference": "12bc8879fb65aef2138b26fc633cb1e3620cffba"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/35ea4b7f3acabb26f4bb640f8c30866c401da807",
-                "reference": "35ea4b7f3acabb26f4bb640f8c30866c401da807",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/12bc8879fb65aef2138b26fc633cb1e3620cffba",
+                "reference": "12bc8879fb65aef2138b26fc633cb1e3620cffba",
                 "shasum": ""
             },
             "require": {
@@ -5105,7 +5104,7 @@
             ],
             "support": {
                 "issues": "https://github.com/sebastianbergmann/phpunit/issues",
-                "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.19"
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.20"
             },
             "funding": [
                 {
@@ -5117,7 +5116,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2022-03-15T09:57:31+00:00"
+            "time": "2022-04-01T12:37:26+00:00"
         },
         {
             "name": "sebastian/cli-parser",
@@ -5485,16 +5484,16 @@
         },
         {
             "name": "sebastian/environment",
-            "version": "5.1.3",
+            "version": "5.1.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/environment.git",
-                "reference": "388b6ced16caa751030f6a69e588299fa09200ac"
+                "reference": "1b5dff7bb151a4db11d49d90e5408e4e938270f7"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/388b6ced16caa751030f6a69e588299fa09200ac",
-                "reference": "388b6ced16caa751030f6a69e588299fa09200ac",
+                "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/1b5dff7bb151a4db11d49d90e5408e4e938270f7",
+                "reference": "1b5dff7bb151a4db11d49d90e5408e4e938270f7",
                 "shasum": ""
             },
             "require": {
@@ -5536,7 +5535,7 @@
             ],
             "support": {
                 "issues": "https://github.com/sebastianbergmann/environment/issues",
-                "source": "https://github.com/sebastianbergmann/environment/tree/5.1.3"
+                "source": "https://github.com/sebastianbergmann/environment/tree/5.1.4"
             },
             "funding": [
                 {
@@ -5544,7 +5543,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2020-09-28T05:52:38+00:00"
+            "time": "2022-04-03T09:37:03+00:00"
         },
         {
             "name": "sebastian/exporter",
@@ -6123,16 +6122,16 @@
         },
         {
             "name": "simplesamlphp/simplesamlphp-module-adfs",
-            "version": "v2.0.0-rc8",
+            "version": "v2.0.0-rc9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/simplesamlphp/simplesamlphp-module-adfs.git",
-                "reference": "ccb86deb3dcb4d9c99483b479534beb22144ad31"
+                "reference": "864c03ae1627854f274b02ff91cfb35e2db08c1b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/simplesamlphp/simplesamlphp-module-adfs/zipball/ccb86deb3dcb4d9c99483b479534beb22144ad31",
-                "reference": "ccb86deb3dcb4d9c99483b479534beb22144ad31",
+                "url": "https://api.github.com/repos/simplesamlphp/simplesamlphp-module-adfs/zipball/864c03ae1627854f274b02ff91cfb35e2db08c1b",
+                "reference": "864c03ae1627854f274b02ff91cfb35e2db08c1b",
                 "shasum": ""
             },
             "require": {
@@ -6142,7 +6141,7 @@
                 "simplesamlphp/xml-security": "~0.4.1"
             },
             "require-dev": {
-                "simplesamlphp/simplesamlphp": "^2.0.0-beta.5",
+                "simplesamlphp/simplesamlphp": "^2.0.0-beta.9",
                 "simplesamlphp/simplesamlphp-test-framework": "^1.1.6"
             },
             "type": "simplesamlphp-module",
@@ -6170,7 +6169,7 @@
                 "issues": "https://github.com/simplesamlphp/simplesamlphp-module-adfs/issues",
                 "source": "https://github.com/simplesamlphp/simplesamlphp-module-adfs"
             },
-            "time": "2022-03-16T16:39:11+00:00"
+            "time": "2022-04-08T10:07:24+00:00"
         },
         {
             "name": "simplesamlphp/simplesamlphp-test-framework",
@@ -6231,16 +6230,16 @@
         },
         {
             "name": "simplesamlphp/xml-common",
-            "version": "v0.8.7",
+            "version": "v0.8.8",
             "source": {
                 "type": "git",
                 "url": "https://github.com/simplesamlphp/xml-common.git",
-                "reference": "de0bdbd52c0f7545b659308b08a37fd8655e6b42"
+                "reference": "b8774b31f9f5a2c4e3c7c37d5eafe3cc4aed2687"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/simplesamlphp/xml-common/zipball/de0bdbd52c0f7545b659308b08a37fd8655e6b42",
-                "reference": "de0bdbd52c0f7545b659308b08a37fd8655e6b42",
+                "url": "https://api.github.com/repos/simplesamlphp/xml-common/zipball/b8774b31f9f5a2c4e3c7c37d5eafe3cc4aed2687",
+                "reference": "b8774b31f9f5a2c4e3c7c37d5eafe3cc4aed2687",
                 "shasum": ""
             },
             "require": {
@@ -6285,20 +6284,20 @@
                 "issues": "https://github.com/simplesamlphp/xml-common/issues",
                 "source": "https://github.com/simplesamlphp/xml-common"
             },
-            "time": "2022-02-17T22:50:21+00:00"
+            "time": "2022-04-08T13:37:27+00:00"
         },
         {
             "name": "simplesamlphp/xml-security",
-            "version": "v0.4.2",
+            "version": "v0.4.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/simplesamlphp/xml-security.git",
-                "reference": "f6640954363b94beb59b0001110b647dcb3a8fa3"
+                "reference": "1d8fc035a06c2be7bfd80b5252a15f4e060fe75c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/simplesamlphp/xml-security/zipball/f6640954363b94beb59b0001110b647dcb3a8fa3",
-                "reference": "f6640954363b94beb59b0001110b647dcb3a8fa3",
+                "url": "https://api.github.com/repos/simplesamlphp/xml-security/zipball/1d8fc035a06c2be7bfd80b5252a15f4e060fe75c",
+                "reference": "1d8fc035a06c2be7bfd80b5252a15f4e060fe75c",
                 "shasum": ""
             },
             "require": {
@@ -6307,11 +6306,11 @@
                 "ext-spl": "*",
                 "php": ">= 7.4 || ^8.0",
                 "robrichards/xmlseclibs": "^3.1.1",
-                "simplesamlphp/assert": "~0.2.12",
-                "simplesamlphp/xml-common": "^0.8.5"
+                "simplesamlphp/assert": "~0.2.13",
+                "simplesamlphp/xml-common": "^0.8.8"
             },
             "require-dev": {
-                "simplesamlphp/simplesamlphp-test-framework": "^1.1.6"
+                "simplesamlphp/simplesamlphp-test-framework": "^1.1.7"
             },
             "type": "library",
             "autoload": {
@@ -6345,9 +6344,9 @@
             ],
             "support": {
                 "issues": "https://github.com/simplesamlphp/xml-security/issues",
-                "source": "https://github.com/simplesamlphp/xml-security/tree/v0.4.2"
+                "source": "https://github.com/simplesamlphp/xml-security/tree/v0.4.3"
             },
-            "time": "2022-02-16T17:17:35+00:00"
+            "time": "2022-04-08T18:57:08+00:00"
         },
         {
             "name": "squizlabs/php_codesniffer",
@@ -6407,16 +6406,16 @@
         },
         {
             "name": "symfony/phpunit-bridge",
-            "version": "v6.0.3",
+            "version": "v6.0.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/phpunit-bridge.git",
-                "reference": "81f5e8e453433e0182a49ca45d4734cb3a2f818f"
+                "reference": "924f44f1c682473453a502f8f01d4904a7761dcc"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/81f5e8e453433e0182a49ca45d4734cb3a2f818f",
-                "reference": "81f5e8e453433e0182a49ca45d4734cb3a2f818f",
+                "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/924f44f1c682473453a502f8f01d4904a7761dcc",
+                "reference": "924f44f1c682473453a502f8f01d4904a7761dcc",
                 "shasum": ""
             },
             "require": {
@@ -6470,7 +6469,7 @@
             "description": "Provides utilities for PHPUnit, especially user deprecation notices management",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/phpunit-bridge/tree/v6.0.3"
+                "source": "https://github.com/symfony/phpunit-bridge/tree/v6.0.7"
             },
             "funding": [
                 {
@@ -6486,7 +6485,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-01-26T17:23:29+00:00"
+            "time": "2022-03-06T11:27:28+00:00"
         },
         {
             "name": "theseer/tokenizer",
diff --git a/docs/simplesamlphp-artifact-idp.md b/docs/simplesamlphp-artifact-idp.md
index fcbf0079d..75286aeaf 100644
--- a/docs/simplesamlphp-artifact-idp.md
+++ b/docs/simplesamlphp-artifact-idp.md
@@ -68,12 +68,12 @@ In general, that should look something like:
     'AssertionConsumerService' => array (
         [
             'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
-            'Location' => 'https://sp.example.org/simplesaml/module.php/saml/sp/saml2-acs.php/default-sp',
+            'Location' => 'https://sp.example.org/simplesaml/module.php/saml/sp/assertionConsumerService/default-sp',
             'index' => 0,
         ],
         [
             'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact',
-            'Location' => 'https://sp.example.org/simplesaml/module.php/saml/sp/saml2-acs.php/default-sp',
+            'Location' => 'https://sp.example.org/simplesaml/module.php/saml/sp/assertionConsumerService/default-sp',
             'index' => 2,
         ],
     ),
diff --git a/docs/simplesamlphp-hok-idp.md b/docs/simplesamlphp-hok-idp.md
index a2315bf74..7bf375e1b 100644
--- a/docs/simplesamlphp-hok-idp.md
+++ b/docs/simplesamlphp-hok-idp.md
@@ -64,12 +64,12 @@ In general, this should look like the following code:
 	'AssertionConsumerService' => array (
 		[
 			'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
-			'Location' => 'https://sp.example.org/simplesaml/module.php/saml/sp/saml2-acs.php/default-sp',
+			'Location' => 'https://sp.example.org/simplesaml/module.php/saml/sp/assertionConsumerService/default-sp',
 			'index' => 0,
 		],
 		[
 			'Binding' => 'urn:oasis:names:tc:SAML:2.0:profiles:holder-of-key:SSO:browser',
-			'Location' => 'https://sp.example.org/simplesaml/module.php/saml/sp/saml2-acs.php/default-sp',
+			'Location' => 'https://sp.example.org/simplesaml/module.php/saml/sp/assertionConsumerService/default-sp',
 			'index' => 4,
 		],
 	),
diff --git a/docs/simplesamlphp-idp.md b/docs/simplesamlphp-idp.md
index 101d57076..a4f623fdf 100644
--- a/docs/simplesamlphp-idp.md
+++ b/docs/simplesamlphp-idp.md
@@ -191,8 +191,8 @@ This is a minimal example of a `metadata/saml20-sp-remote.php` metadata file for
 
     <?php
     $metadata['https://sp.example.org/simplesaml/module.php/saml/sp/metadata.php/default-sp'] = [
-        'AssertionConsumerService' => 'https://sp.example.org/simplesaml/module.php/saml/sp/saml2-acs.php/default-sp',
-        'SingleLogoutService'      => 'https://sp.example.org/simplesaml/module.php/saml/sp/saml2-logout.php/default-sp',
+        'AssertionConsumerService' => 'https://sp.example.org/simplesaml/module.php/saml/sp/assertionConsumerService/default-sp',
+        'SingleLogoutService'      => 'https://sp.example.org/simplesaml/module.php/saml/sp/singleLogoutService/default-sp',
     ];
 
 Note that the URI in the entityID and the URLs to the AssertionConsumerService and SingleLogoutService endpoints change between different service providers.
diff --git a/lib/SimpleSAML/Configuration.php b/lib/SimpleSAML/Configuration.php
index 23587bfa0..1bb5eaeed 100644
--- a/lib/SimpleSAML/Configuration.php
+++ b/lib/SimpleSAML/Configuration.php
@@ -1015,7 +1015,8 @@ class Configuration implements Utils\ClearableState
      * @param string[]|null  $default A default value which will be returned if the option isn't found.
      *                         The default value can be null or an array of strings.
      *
-     * @return string[]|null The option with the given name, or $default if the option isn't found and $default is specified.
+     * @return string[]|null The option with the given name, or $default if the option isn't found
+     *                         and $default is specified.
      * @psalm-return         ($default is set ? array|null : array)
      *
      * @throws \SimpleSAML\Assert\AssertionFailedException If the option is not a string or an array of strings.
@@ -1082,7 +1083,10 @@ class Configuration implements Utils\ClearableState
     {
         $ret = $this->getOptionalArray($name, $default);
 
-        return ($ret === null) ? null : self::loadFromArray($ret, $this->location . '[' . var_export($name, true) . ']');
+        if ($ret !== null) {
+            return self::loadFromArray($ret, $this->location . '[' . var_export($name, true) . ']');
+        }
+        return null;
     }
 
 
diff --git a/lib/SimpleSAML/Database.php b/lib/SimpleSAML/Database.php
index 239b88eb6..1c2105fa7 100644
--- a/lib/SimpleSAML/Database.php
+++ b/lib/SimpleSAML/Database.php
@@ -105,7 +105,10 @@ class Database
             );
         }
         // connect to any configured secondaries, preserving legacy config option
-        $secondaries = $config->getOptionalArray('database.secondaries', $config->getOptionalArray('database.slaves', []));
+        $secondaries = $config->getOptionalArray(
+            'database.secondaries',
+            $config->getOptionalArray('database.slaves', [])
+        );
         foreach ($secondaries as $secondary) {
             array_push(
                 $this->dbSecondaries,
@@ -140,7 +143,10 @@ class Database
             ],
 
             // TODO: deprecated: the "database.slave" terminology is preserved here for backwards compatibility.
-            'secondaries' => $config->getOptionalArray('database.secondaries', $config->getOptionalArray('database.slaves', [])),
+            'secondaries' => $config->getOptionalArray(
+                'database.secondaries',
+                $config->getOptionalArray('database.slaves', [])
+            ),
         ];
 
         return sha1(serialize($assembledConfig));
diff --git a/lib/SimpleSAML/Logger/SyslogLoggingHandler.php b/lib/SimpleSAML/Logger/SyslogLoggingHandler.php
index 40a0923f6..1bff3034a 100644
--- a/lib/SimpleSAML/Logger/SyslogLoggingHandler.php
+++ b/lib/SimpleSAML/Logger/SyslogLoggingHandler.php
@@ -27,7 +27,10 @@ class SyslogLoggingHandler implements LoggingHandlerInterface
      */
     public function __construct(Configuration $config)
     {
-        $facility = $config->getOptionalInteger('logging.facility', defined('LOG_LOCAL5') ? constant('LOG_LOCAL5') : LOG_USER);
+        $facility = $config->getOptionalInteger(
+            'logging.facility',
+            defined('LOG_LOCAL5') ? constant('LOG_LOCAL5') : LOG_USER
+        );
 
         // Remove any non-printable characters before storing
         $processname = preg_replace(
diff --git a/lib/SimpleSAML/XHTML/IdPDisco.php b/lib/SimpleSAML/XHTML/IdPDisco.php
index 852a2d6de..d141f48d2 100644
--- a/lib/SimpleSAML/XHTML/IdPDisco.php
+++ b/lib/SimpleSAML/XHTML/IdPDisco.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace SimpleSAML\XHTML;
 
+use Exception;
 use SimpleSAML\Assert\Assert;
 use SimpleSAML\Configuration;
 use SimpleSAML\Logger;
@@ -11,6 +12,19 @@ use SimpleSAML\Metadata\MetaDataStorageHandler;
 use SimpleSAML\Session;
 use SimpleSAML\Utils;
 
+use function array_fill_keys;
+use function array_intersect_key;
+use function array_intersect;
+use function array_key_exists;
+use function array_keys;
+use function array_merge;
+use function htmlspecialchars;
+use function preg_match;
+use function sizeof;
+use function strcasecmp;
+use function urldecode;
+use function usort;
+
 /**
  * This class implements a generic IdP discovery service, for use in various IdP
  * discovery service pages. This should reduce code duplication.
@@ -128,7 +142,7 @@ class IdPDisco
 
         // standard discovery service parameters
         if (!array_key_exists('entityID', $_GET)) {
-            throw new \Exception('Missing parameter: entityID');
+            throw new Exception('Missing parameter: entityID');
         } else {
             $this->spEntityId = $_GET['entityID'];
         }
@@ -142,7 +156,7 @@ class IdPDisco
         $this->log('returnIdParam initially set to [' . $this->returnIdParam . ']');
 
         if (!array_key_exists('return', $_GET)) {
-            throw new \Exception('Missing parameter: return');
+            throw new Exception('Missing parameter: return');
         } else {
             $httpUtils = new Utils\HTTP();
             $this->returnURL = $httpUtils->checkURLAllowed($_GET['return']);
@@ -251,7 +265,7 @@ class IdPDisco
             try {
                 $this->metadata->getMetaData($idp, $metadataSet);
                 return $idp;
-            } catch (\Exception $e) {
+            } catch (Exception $e) {
                 // continue
             }
         }
@@ -584,7 +598,7 @@ class IdPDisco
                 $templateFile = 'selectidp-links.twig';
                 break;
             default:
-                throw new \Exception('Invalid value for the \'idpdisco.layout\' option.');
+                throw new Exception('Invalid value for the \'idpdisco.layout\' option.');
         }
 
         $t = new Template($this->config, $templateFile);
diff --git a/metadata-templates/saml20-sp-remote.php b/metadata-templates/saml20-sp-remote.php
index ef2f81e96..2cf2873ca 100644
--- a/metadata-templates/saml20-sp-remote.php
+++ b/metadata-templates/saml20-sp-remote.php
@@ -10,8 +10,8 @@
  * Example SimpleSAMLphp SAML 2.0 SP
  */
 $metadata['https://saml2sp.example.org'] = [
-    'AssertionConsumerService' => 'https://saml2sp.example.org/simplesaml/module.php/saml/sp/saml2-acs.php/default-sp',
-    'SingleLogoutService' => 'https://saml2sp.example.org/simplesaml/module.php/saml/sp/saml2-logout.php/default-sp',
+    'AssertionConsumerService' => 'https://saml2sp.example.org/simplesaml/module.php/saml/sp/assertionConsumerService/default-sp',
+    'SingleLogoutService' => 'https://saml2sp.example.org/simplesaml/module.php/saml/sp/singleLogoutService/default-sp',
 ];
 
 /*
diff --git a/modules/admin/lib/Controller/Federation.php b/modules/admin/lib/Controller/Federation.php
index 6969ca17d..6b8e08a15 100644
--- a/modules/admin/lib/Controller/Federation.php
+++ b/modules/admin/lib/Controller/Federation.php
@@ -331,7 +331,10 @@ class Federation
             // get the name
             $name = $source->getMetadata()->getOptionalLocalizedString(
                 'name',
-                $source->getMetadata()->getOptionalLocalizedString('OrganizationDisplayName', ['en' => $source->getAuthId()])
+                $source->getMetadata()->getOptionalLocalizedString(
+                    'OrganizationDisplayName',
+                    ['en' => $source->getAuthId()]
+                )
             );
 
             $builder = new SAMLBuilder($source->getEntityId());
diff --git a/modules/core/docs/authproc_attributelimit.md b/modules/core/docs/authproc_attributelimit.md
index 220fd54d7..90bf2a738 100644
--- a/modules/core/docs/authproc_attributelimit.md
+++ b/modules/core/docs/authproc_attributelimit.md
@@ -86,8 +86,8 @@ like this:
 Then, add the allowed attributes to each service provider metadata, in the `attributes` option:
 
     $metadata['https://saml2sp.example.org'] = [
-        'AssertionConsumerService' => 'https://saml2sp.example.org/simplesaml/module.php/saml/sp/saml2-acs.php/default-sp',
-        'SingleLogoutService' => 'https://saml2sp.example.org/simplesaml/module.php/saml/sp/saml2-logout.php/default-sp',
+        'AssertionConsumerService' => 'https://saml2sp.example.org/simplesaml/module.php/saml/sp/assertionConsumerService/default-sp',
+        'SingleLogoutService' => 'https://saml2sp.example.org/simplesaml/module.php/saml/sp/singleLogoutService/default-sp',
         ...
         'attributes' => ['cn', 'mail'],
         ...
@@ -97,8 +97,8 @@ Now, let's look to a couple of examples on how to filter out attribute values. F
 to be used by a service provider (among other attributes):
 
     $metadata['https://saml2sp.example.org'] = [
-        'AssertionConsumerService' => 'https://saml2sp.example.org/simplesaml/module.php/saml/sp/saml2-acs.php/default-sp',
-        'SingleLogoutService' => 'https://saml2sp.example.org/simplesaml/module.php/saml/sp/saml2-logout.php/default-sp',
+        'AssertionConsumerService' => 'https://saml2sp.example.org/simplesaml/module.php/saml/sp/assertionConsumerService/default-sp',
+        'SingleLogoutService' => 'https://saml2sp.example.org/simplesaml/module.php/saml/sp/singleLogoutService/default-sp',
         ...
         'attributes' => [
             'uid',
diff --git a/modules/core/lib/Auth/Process/AttributeAlter.php b/modules/core/lib/Auth/Process/AttributeAlter.php
index 6b851d7a0..fb3551a0c 100644
--- a/modules/core/lib/Auth/Process/AttributeAlter.php
+++ b/modules/core/lib/Auth/Process/AttributeAlter.php
@@ -163,7 +163,7 @@ class AttributeAlter extends Auth\ProcessingFilter
 
                     if ($this->subject === $this->target) {
                         $value = $new_value;
-                    } else if ($this->merge === true) {
+                    } elseif ($this->merge === true) {
                         $attributes[$this->target] = array_values(
                             array_diff($attributes[$this->target], [$value])
                         );
diff --git a/modules/core/lib/Controller/Logout.php b/modules/core/lib/Controller/Logout.php
index 6b1bbf52d..8adcb5876 100644
--- a/modules/core/lib/Controller/Logout.php
+++ b/modules/core/lib/Controller/Logout.php
@@ -310,7 +310,8 @@ class Logout
                     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->getOptionalInteger('core:logout-timeout', 5) + time();
+                        $timeout = $assocConfig->getOptionalInteger('core:logout-timeout', 5);
+                        $sp['core:Logout-IFrame:Timeout'] = $timeout + time();
                     } else {
                         $sp['core:Logout-IFrame:Timeout'] = time() + 5;
                     }
diff --git a/modules/saml/lib/Auth/Source/SP.php b/modules/saml/lib/Auth/Source/SP.php
index e17726f5a..c7b5a0d12 100644
--- a/modules/saml/lib/Auth/Source/SP.php
+++ b/modules/saml/lib/Auth/Source/SP.php
@@ -179,7 +179,10 @@ class SP extends \SimpleSAML\Auth\Source
         $org = $this->metadata->getOptionalLocalizedString('OrganizationName', null);
         if ($org !== null) {
             $metadata['OrganizationName'] = $org;
-            $metadata['OrganizationDisplayName'] = $this->metadata->getOptionalLocalizedString('OrganizationDisplayName', $org);
+            $metadata['OrganizationDisplayName'] = $this->metadata->getOptionalLocalizedString(
+                'OrganizationDisplayName',
+                $org
+            );
             $metadata['OrganizationURL'] = $this->metadata->getOptionalLocalizedString('OrganizationURL', null);
             if ($metadata['OrganizationURL'] === null) {
                 throw new Error\Exception(
@@ -350,19 +353,19 @@ class SP extends \SimpleSAML\Auth\Source
                 case Constants::BINDING_HTTP_POST:
                     $acs = [
                         'Binding' => Constants::BINDING_HTTP_POST,
-                        'Location' => Module::getModuleURL('saml/sp/saml2-acs.php/' . $this->getAuthId()),
+                        'Location' => Module::getModuleURL('saml/sp/assertionConsumerService/' . $this->getAuthId()),
                     ];
                     break;
                 case Constants::BINDING_HTTP_ARTIFACT:
                     $acs = [
                         'Binding' => Constants::BINDING_HTTP_ARTIFACT,
-                        'Location' => Module::getModuleURL('saml/sp/saml2-acs.php/' . $this->getAuthId()),
+                        'Location' => Module::getModuleURL('saml/sp/assertionConsumerService/' . $this->getAuthId()),
                     ];
                     break;
                 case Constants::BINDING_HOK_SSO:
                     $acs = [
                         'Binding' => Constants::BINDING_HOK_SSO,
-                        'Location' => Module::getModuleURL('saml/sp/saml2-acs.php/' . $this->getAuthId()),
+                        'Location' => Module::getModuleURL('saml/sp/assertionConsumerService/' . $this->getAuthId()),
                         'hoksso:ProtocolBinding' => Constants::BINDING_HTTP_REDIRECT,
                     ];
                     break;
@@ -397,7 +400,7 @@ class SP extends \SimpleSAML\Auth\Source
                 Constants::BINDING_SOAP,
             ]
         );
-        $defaultLocation = Module::getModuleURL('saml/sp/saml2-logout.php/' . $this->getAuthId());
+        $defaultLocation = Module::getModuleURL('saml/sp/singleLogoutService/' . $this->getAuthId());
         $location = $this->metadata->getOptionalString('SingleLogoutServiceLocation', $defaultLocation);
 
         $endpoints = [];
@@ -432,7 +435,7 @@ class SP extends \SimpleSAML\Auth\Source
 
         $ar = Module\saml\Message::buildAuthnRequest($this->metadata, $idpMetadata);
 
-        $ar->setAssertionConsumerServiceURL(Module::getModuleURL('saml/sp/saml2-acs.php/' . $this->authId));
+        $ar->setAssertionConsumerServiceURL(Module::getModuleURL('saml/sp/assertionConsumerService/' . $this->authId));
 
         if (isset($state['\SimpleSAML\Auth\Source.ReturnURL'])) {
             $ar->setRelayState($state['\SimpleSAML\Auth\Source.ReturnURL']);
@@ -686,10 +689,10 @@ class SP extends \SimpleSAML\Auth\Source
         $discoURL = $this->discoURL;
         if ($discoURL === null) {
             // Fallback to internal discovery service
-            $discoURL = Module::getModuleURL('saml/disco.php');
+            $discoURL = Module::getModuleURL('saml/disco');
         }
 
-        $returnTo = Module::getModuleURL('saml/sp/discoresp.php', ['AuthID' => $id]);
+        $returnTo = Module::getModuleURL('saml/sp/discoResponse', ['AuthID' => $id]);
 
         $params = [
             'entityID' => $this->entityId,
diff --git a/modules/saml/lib/Controller/Disco.php b/modules/saml/lib/Controller/Disco.php
new file mode 100644
index 000000000..24cf78a3e
--- /dev/null
+++ b/modules/saml/lib/Controller/Disco.php
@@ -0,0 +1,49 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\saml\Controller;
+
+use SimpleSAML\Configuration;
+use SimpleSAML\HTTP\RunnableResponse;
+use SimpleSAML\XHTML\IdPDisco;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Controller class for the saml module.
+ *
+ * This class serves the different views available in the module.
+ *
+ * @package simplesamlphp/simplesamlphp
+ */
+class Disco
+{
+    /** @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;
+    }
+
+
+    /**
+     * Built-in IdP discovery service
+     *
+     * @return \SimpleSAML\Http\RunnableResponse
+     */
+    public function disco(): RunnableResponse
+    {
+        $disco = new IdPDisco(['saml20-idp-remote'], 'saml');
+        return new RunnableResponse([$disco, 'handleRequest']);
+    }
+}
diff --git a/modules/saml/lib/Controller/Proxy.php b/modules/saml/lib/Controller/Proxy.php
new file mode 100644
index 000000000..a02a29f7d
--- /dev/null
+++ b/modules/saml/lib/Controller/Proxy.php
@@ -0,0 +1,122 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\saml\Controller;
+
+use Exception;
+use SAML2\Constants;
+use SimpleSAML\Assert\Assert;
+use SimpleSAML\Auth;
+use SimpleSAML\Configuration;
+use SimpleSAML\Error;
+use SimpleSAML\HTTP\RunnableResponse;
+use SimpleSAML\IdP;
+use SimpleSAML\Module\saml\Auth\Source\SP;
+use SimpleSAML\Module\saml\Error\NoAvailableIDP;
+use SimpleSAML\XHTML\Template;
+use Symfony\Component\HttpFoundation\{Request, Response};
+
+/**
+ * Controller class for the saml module.
+ *
+ * This class serves the different views available in the module.
+ *
+ * @package simplesamlphp/simplesamlphp
+ */
+class Proxy
+{
+    /** @var \SimpleSAML\Configuration */
+    protected Configuration $config;
+
+    /**
+     * @var \SimpleSAML\Auth\State|string
+     * @psalm-var \SimpleSAML\Auth\State|class-string
+     */
+    protected $authState = Auth\State::class;
+
+
+    /**
+     * 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;
+    }
+
+
+    /**
+     * Inject the \SimpleSAML\Auth\State dependency.
+     *
+     * @param \SimpleSAML\Auth\State $authState
+     */
+    public function setAuthState(Auth\State $authState): void
+    {
+        $this->authState = $authState;
+    }
+
+
+    /**
+     * This controller will handle the case of a user with an existing session that's not valid for a specific
+     * Service Provider, since the authenticating IdP is not in the list of IdPs allowed by the SP.
+     *
+     * @param \Symfony\Component\HttpFoundation\Request $request
+     * @return \SimpleSAML\XHTML\Template|\Symfony\Component\HttpFoundation\Response
+     */
+    public function invalidSession(Request $request): Response
+    {
+        // retrieve the authentication state
+        if (!$request->query->has('AuthState')) {
+            throw new Error\BadRequest('Missing mandatory parameter: AuthState');
+        }
+        $stateId = $request->query->get('AuthState');
+
+        try {
+            // try to get the state
+            $state = $this->authState::loadState($stateId, 'saml:proxy:invalid_idp');
+        } catch (Exception $e) {
+            // the user probably hit the back button after starting the logout,
+            // try to recover the state with another stage
+            $state = $this->authState::loadState($stateId, 'core:Logout:afterbridge');
+
+            // success! Try to continue with reauthentication, since we no longer have a valid session here
+            $idp = IdP::getById($state['core:IdP']);
+            return new RunnableResponse([SP::class, 'reauthPostLogout'], [$idp, $state]);
+        }
+
+        if ($request->request->has('cancel')) {
+            // the user does not want to logout, cancel login
+            $this->authState::throwException(
+                $state,
+                new NoAvailableIDP(
+                    Constants::STATUS_RESPONDER,
+                    'User refused to reauthenticate with any of the IdPs requested.'
+                )
+            );
+        }
+
+        if ($request->request->has('continue')) {
+            /** @var \SimpleSAML\Module\saml\Auth\Source\SP $as */
+            $as = Auth\Source::getById($state['saml:sp:AuthId'], SP::class);
+
+            // log the user out before being able to login again
+            return new RunnableResponse([$as, 'reauthLogout'], [$state]);
+        }
+
+        $template = new Template($this->config, 'saml:proxy/invalid_session.twig');
+        $template->data['AuthState'] = $stateId;
+
+        /** @var \SimpleSAML\Configuration $idpmdcfg */
+        $idpmdcfg = $state['saml:sp:IdPMetadata'];
+
+        $template->data['entity_idp'] = $idpmdcfg->toArray();
+        $template->data['entity_sp'] = $state['SPMetadata'];
+
+        return $template;
+    }
+}
diff --git a/modules/saml/lib/Controller/ServiceProvider.php b/modules/saml/lib/Controller/ServiceProvider.php
new file mode 100644
index 000000000..bf48408ae
--- /dev/null
+++ b/modules/saml/lib/Controller/ServiceProvider.php
@@ -0,0 +1,613 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\saml\Controller;
+
+use Exception;
+use SAML2\Assertion;
+use SAML2\Binding;
+use SAML2\Constants;
+use SAML2\Exception\Protocol\UnsupportedBindingException;
+use SAML2\HTTPArtifact;
+use SAML2\LogoutRequest;
+use SAML2\LogoutResponse;
+use SAML2\Response as SAML2_Response;
+use SAML2\SOAP;
+use SAML2\XML\saml\Issuer;
+use SimpleSAML\Assert\Assert;
+use SimpleSAML\Auth;
+use SimpleSAML\Configuration;
+use SimpleSAML\Error;
+use SimpleSAML\HTTP\RunnableResponse;
+use SimpleSAML\Logger;
+use SimpleSAML\Metadata;
+use SimpleSAML\Module;
+use SimpleSAML\Module\saml\Auth\Source\SP;
+use SimpleSAML\Session;
+use SimpleSAML\Store\StoreFactory;
+use SimpleSAML\Utils;
+use SimpleSAML\XHTML\Template;
+use Symfony\Component\HttpFoundation\{Request, Response};
+
+use function array_merge;
+use function count;
+use function end;
+use function get_class;
+use function in_array;
+use function is_null;
+use function substr;
+use function time;
+use function var_export;
+
+/**
+ * Controller class for the saml module.
+ *
+ * This class serves the different views available in the module.
+ *
+ * @package simplesamlphp/simplesamlphp
+ */
+class ServiceProvider
+{
+    /** @var \SimpleSAML\Configuration */
+    protected Configuration $config;
+
+    /** @var \SimpleSAML\Session */
+    protected Session $session;
+
+    /**
+     * @var \SimpleSAML\Auth\State|string
+     * @psalm-var \SimpleSAML\Auth\State|class-string
+     */
+    protected $authState = Auth\State::class;
+
+    /** @var \SimpleSAML\Utils\Auth */
+    protected Utils\Auth $authUtils;
+
+
+    /**
+     * Controller constructor.
+     *
+     * It initializes the global 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.
+     */
+    public function __construct(
+        Configuration $config,
+        Session $session
+    ) {
+        $this->config = $config;
+        $this->session = $session;
+        $this->authUtils = new Utils\Auth();
+    }
+
+
+    /**
+     * Inject the \SimpleSAML\Auth\State dependency.
+     *
+     * @param \SimpleSAML\Auth\State $authState
+     */
+    public function setAuthState(Auth\State $authState): void
+    {
+        $this->authState = $authState;
+    }
+
+
+    /**
+     * Inject the \SimpleSAML\Utils\Auth dependency.
+     *
+     * @param \SimpleSAML\Utils\Auth $authUtils
+     */
+    public function setAuthUtils(Utils\Auth $authUtils): void
+    {
+        $this->authUtils = $authUtils;
+    }
+
+
+    /**
+     * Handler for response from IdP discovery service.
+     *
+     * @param \Symfony\Component\HttpFoundation\Request $request
+     * @return \SimpleSAML\Http\RunnableResponse
+     */
+    public function discoResponse(Request $request): RunnableResponse
+    {
+        if (!$request->query->has('AuthID')) {
+            throw new Error\BadRequest('Missing AuthID to discovery service response handler');
+        }
+        $authId = $request->query->get('AuthID');
+
+        if (!$request->query->has('idpentityid')) {
+            throw new Error\BadRequest('Missing idpentityid to discovery service response handler');
+        }
+        $idpEntityId = $request->query->get('idpentityid');
+
+        $state = $this->authState::loadState($authId, 'saml:sp:sso');
+
+        // Find authentication source
+        Assert::keyExists($state, 'saml:sp:AuthId');
+        $sourceId = $state['saml:sp:AuthId'];
+
+        $source = Auth\Source::getById($sourceId);
+        if ($source === null) {
+            throw new Exception('Could not find authentication source with id ' . $sourceId);
+        }
+
+        if (!($source instanceof SP)) {
+            throw new Error\Exception('Source type changed?');
+        }
+
+        return new RunnableResponse([$source, 'startSSO'], [$idpEntityId, $state]);
+    }
+
+
+    /**
+     * @return \SimpleSAML\XHTML\Template
+     */
+    public function wrongAuthnContextClassRef(): Template
+    {
+        return new Template($this->config, 'saml:sp/wrong_authncontextclassref.twig');
+    }
+
+
+    /**
+     * Handler for the Assertion Consumer Service.
+     *
+     * @param string $sourceId
+     * @return \SimpleSAML\Http\RunnableResponse
+     */
+    public function assertionConsumerService(string $sourceId): RunnableResponse
+    {
+        /** @var \SimpleSAML\Module\saml\Auth\Source\SP $source */
+        $source = Auth\Source::getById($sourceId, SP::class);
+
+        $spMetadata = $source->getMetadata();
+        try {
+            $b = Binding::getCurrentBinding();
+        } catch (UnsupportedBindingException $e) {
+            throw new Error\Error('ACSPARAMS', $e, 400);
+        }
+
+        if ($b instanceof HTTPArtifact) {
+            $b->setSPMetadata($spMetadata);
+        }
+
+        $response = $b->receive();
+        if (!($response instanceof SAML2_Response)) {
+            throw new Error\BadRequest('Invalid message received at AssertionConsumerService endpoint.');
+        }
+
+        $issuer = $response->getIssuer();
+        if ($issuer === null) {
+            // no Issuer in the response. Look for an unencrypted assertion with an issuer
+            foreach ($response->getAssertions() as $a) {
+                if ($a instanceof Assertion) {
+                    // we found an unencrypted assertion, there should be an issuer here
+                    $issuer = $a->getIssuer();
+                    break;
+                }
+            }
+            if ($issuer === null) {
+                // no issuer found in the assertions
+                throw new Exception('Missing <saml:Issuer> in message delivered to AssertionConsumerService.');
+            }
+        }
+        $issuer = $issuer->getValue();
+
+        $prevAuth = $this->session->getAuthData($sourceId, 'saml:sp:prevAuth');
+
+        $httpUtils = new Utils\HTTP();
+        if ($prevAuth !== null && $prevAuth['id'] === $response->getId() && $prevAuth['issuer'] === $issuer) {
+            /**
+             * OK, it looks like this message has the same issuer
+             * and ID as the SP session we already have active. We
+             * therefore assume that the user has somehow triggered
+             * a resend of the message.
+             * In that case we may as well just redo the previous redirect
+             * instead of displaying a confusing error message.
+             */
+            Logger::info(
+                'Duplicate SAML 2 response detected - ignoring the response and redirecting the user to the correct page.'
+            );
+            if (isset($prevAuth['redirect'])) {
+                return new RunnableResponse([$httpUtils, 'redirectTrustedURL'], [$prevAuth['redirect']]);
+            }
+
+            Logger::info('No RelayState or ReturnURL available, cannot redirect.');
+            throw new Error\Exception('Duplicate assertion received.');
+        }
+
+        $idpMetadata = null;
+        $state = null;
+        $stateId = $response->getInResponseTo();
+
+        if (!empty($stateId)) {
+            // this should be a response to a request we sent earlier
+            try {
+                $state = $this->authState::loadState($stateId, 'saml:sp:sso');
+            } catch (Exception $e) {
+                // something went wrong,
+                Logger::warning(sprintf(
+                    'Could not load state specified by InResponseTo: %s Processing response as unsolicited.',
+                    $e->getMessage(),
+                ));
+            }
+        }
+
+        $enableUnsolicited = $spMetadata->getOptionalBoolean('enable_unsolicited', true);
+        if ($state === null && $enableUnsolicited === false) {
+            throw new Error\BadRequest('Unsolicited responses are denied by configuration.');
+        }
+
+        if ($state) {
+            // check that the authentication source is correct
+            Assert::keyExists($state, 'saml:sp:AuthId');
+            if ($state['saml:sp:AuthId'] !== $sourceId) {
+                throw new Error\Exception(
+                    'The authentication source id in the URL does not match the authentication source which sent the request.'
+                );
+            }
+
+            // check that the issuer is the one we are expecting
+            Assert::keyExists($state, 'ExpectedIssuer');
+            if ($state['ExpectedIssuer'] !== $issuer) {
+                $idpMetadata = $source->getIdPMetadata($issuer);
+                $idplist = $idpMetadata->getOptionalArrayize('IDPList', []);
+                if (!in_array($state['ExpectedIssuer'], $idplist, true)) {
+                    Logger::warning(
+                        'The issuer of the response not match to the identity provider we sent the request to.'
+                    );
+                }
+            }
+        } else {
+            // this is an unsolicited response
+            $relaystate = $spMetadata->getOptionalString('RelayState', $response->getRelayState());
+            $state = [
+                'saml:sp:isUnsolicited' => true,
+                'saml:sp:AuthId'        => $sourceId,
+                'saml:sp:RelayState'    => $relaystate === null ? null : $httpUtils->checkURLAllowed($relaystate),
+            ];
+        }
+
+        Logger::debug('Received SAML2 Response from ' . var_export($issuer, true) . '.');
+
+        if (is_null($idpMetadata)) {
+            $idpMetadata = $source->getIdPmetadata($issuer);
+        }
+
+        try {
+            $assertions = Module\saml\Message::processResponse($spMetadata, $idpMetadata, $response);
+        } catch (Module\saml\Error $e) {
+            // the status of the response wasn't "success"
+            $e = $e->toException();
+            $this->authState::throwException($state, $e);
+            Assert::true(false);
+        }
+
+        $authenticatingAuthority = null;
+        $nameId = null;
+        $sessionIndex = null;
+        $expire = null;
+        $attributes = [];
+        $foundAuthnStatement = false;
+
+        $storeType = $this->config->getOptionalString('store.type', 'phpsession');
+
+        $store = StoreFactory::getInstance($storeType);
+
+        foreach ($assertions as $assertion) {
+            // check for duplicate assertion (replay attack)
+            if ($store !== false) {
+                $aID = $assertion->getId();
+                if ($store->get('saml.AssertionReceived', $aID) !== null) {
+                    $e = new Error\Exception('Received duplicate assertion.');
+                    $this->authState::throwException($state, $e);
+                }
+
+                $notOnOrAfter = $assertion->getNotOnOrAfter();
+                if ($notOnOrAfter === null) {
+                    $notOnOrAfter = time() + 24 * 60 * 60;
+                } else {
+                    $notOnOrAfter += 60; // we allow 60 seconds clock skew, so add it here also
+                }
+
+                $store->set('saml.AssertionReceived', $aID, true, $notOnOrAfter);
+            }
+
+            if ($authenticatingAuthority === null) {
+                $authenticatingAuthority = $assertion->getAuthenticatingAuthority();
+            }
+            if ($nameId === null) {
+                $nameId = $assertion->getNameId();
+            }
+            if ($sessionIndex === null) {
+                $sessionIndex = $assertion->getSessionIndex();
+            }
+            if ($expire === null) {
+                $expire = $assertion->getSessionNotOnOrAfter();
+            }
+
+            $attributes = array_merge($attributes, $assertion->getAttributes());
+
+            if ($assertion->getAuthnInstant() !== null) {
+                // assertion contains AuthnStatement, since AuthnInstant is a required attribute
+                $foundAuthnStatement = true;
+            }
+        }
+        $assertion = end($assertions);
+
+        if (!$foundAuthnStatement) {
+            $e = new Error\Exception('No AuthnStatement found in assertion(s).');
+            $this->authState::throwException($state, $e);
+        }
+
+        if ($expire !== null) {
+            $logoutExpire = $expire;
+        } else {
+            // just expire the logout association 24 hours into the future
+            $logoutExpire = time() + 24 * 60 * 60;
+        }
+
+        if (!empty($nameId)) {
+            // register this session in the logout store
+            Module\saml\SP\LogoutStore::addSession($sourceId, $nameId, $sessionIndex, $logoutExpire);
+
+            // we need to save the NameID and SessionIndex for logout
+            $logoutState = [
+                'saml:logout:Type'         => 'saml2',
+                'saml:logout:IdP'          => $issuer,
+                'saml:logout:NameID'       => $nameId,
+                'saml:logout:SessionIndex' => $sessionIndex,
+            ];
+
+            $state['saml:sp:NameID'] = $nameId; // no need to mark it as persistent, it already is
+        } else {
+            /*
+             * No NameID provided, we can't logout from this IdP!
+             *
+             * Even though interoperability profiles "require" a NameID, the SAML 2.0 standard does not require
+             * it to be present in assertions. That way, we could have a Subject with only a SubjectConfirmation,
+             * or even no Subject element at all.
+             *
+             * In case we receive a SAML assertion with no NameID, we can be graceful and continue, but we won't
+             * be able to perform a Single Logout since the SAML logout profile mandates the use of a NameID to
+             * identify the individual we want to be logged out. In order to minimize the impact of this, we keep
+             * logout state information (without saving it to the store), marking the IdP as SAML 1.0, which
+             * does not implement logout. Then we can safely log the user out from the local session, skipping
+             * Single Logout upstream to the IdP.
+             */
+            $logoutState = [
+                'saml:logout:Type'         => 'saml1',
+            ];
+        }
+
+        $state['LogoutState'] = $logoutState;
+        $state['saml:AuthenticatingAuthority'] = $authenticatingAuthority;
+        $state['saml:AuthenticatingAuthority'][] = $issuer;
+        $state['PersistentAuthData'][] = 'saml:AuthenticatingAuthority';
+        $state['saml:AuthnInstant'] = $assertion->getAuthnInstant();
+        $state['PersistentAuthData'][] = 'saml:AuthnInstant';
+        $state['saml:sp:SessionIndex'] = $sessionIndex;
+        $state['PersistentAuthData'][] = 'saml:sp:SessionIndex';
+        $state['saml:sp:AuthnContext'] = $assertion->getAuthnContextClassRef();
+        $state['PersistentAuthData'][] = 'saml:sp:AuthnContext';
+
+        if ($expire !== null) {
+            $state['Expire'] = $expire;
+        }
+
+        // note some information about the authentication, in case we receive the same response again
+        $state['saml:sp:prevAuth'] = [
+            'id'     => $response->getId(),
+            'issuer' => $issuer,
+            'inResponseTo' => $response->getInResponseTo(),
+        ];
+        if (isset($state['\SimpleSAML\Auth\Source.ReturnURL'])) {
+            $state['saml:sp:prevAuth']['redirect'] = $state['\SimpleSAML\Auth\Source.ReturnURL'];
+        } elseif (isset($state['saml:sp:RelayState'])) {
+            $state['saml:sp:prevAuth']['redirect'] = $state['saml:sp:RelayState'];
+        }
+        $state['PersistentAuthData'][] = 'saml:sp:prevAuth';
+
+        return new RunnableResponse([$source, 'handleResponse'], [$state, $issuer, $attributes]);
+    }
+
+
+    /**
+     * Logout endpoint handler for SAML SP authentication client.
+     *
+     * This endpoint handles both logout requests and logout responses.
+     *
+     * @param string $sourceId
+     * @return \SimpleSAML\Http\RunnableResponse
+     */
+    public function singleLogoutService(string $sourceId): RunnableResponse
+    {
+        /** @var \SimpleSAML\Module\saml\Auth\Source\SP $source */
+        $source = Auth\Source::getById($sourceId);
+
+        if ($source === null) {
+            throw new Error\Exception('No authentication source with id \'' . $sourceId . '\' found.');
+        } elseif (!($source instanceof \SimpleSAML\Module\saml\Auth\Source\SP)) {
+            throw new Error\Exception('Source type changed?');
+        }
+
+        try {
+            $binding = Binding::getCurrentBinding();
+        } catch (UnsupportedBindingException $e) {
+            throw new Error\Error('SLOSERVICEPARAMS', $e, 400);
+        }
+        $message = $binding->receive();
+
+        $issuer = $message->getIssuer();
+        if ($issuer instanceof Issuer) {
+            $idpEntityId = $issuer->getValue();
+        } else {
+            $idpEntityId = $issuer;
+        }
+
+        if ($idpEntityId === null) {
+            // Without an issuer we have no way to respond to the message.
+            throw new Error\BadRequest('Received message on logout endpoint without issuer.');
+        }
+
+        $spEntityId = $source->getEntityId();
+
+        $idpMetadata = $source->getIdPMetadata($idpEntityId);
+        $spMetadata = $source->getMetadata();
+
+        Module\saml\Message::validateMessage($idpMetadata, $spMetadata, $message);
+
+        $httpUtils = new Utils\HTTP();
+        $destination = $message->getDestination();
+        if ($destination !== null && $destination !== $httpUtils->getSelfURLNoQuery()) {
+            throw new Error\Exception('Destination in logout message is wrong.');
+        }
+
+        if ($message instanceof LogoutResponse) {
+            $relayState = $message->getRelayState();
+            if ($relayState === null) {
+                // Somehow, our RelayState has been lost.
+                throw new Error\BadRequest('Missing RelayState in logout response.');
+            }
+
+            if (!$message->isSuccess()) {
+                Logger::warning(
+                    'Unsuccessful logout. Status was: ' . Module\saml\Message::getResponseError($message)
+                );
+            }
+
+            $state = $this->authState::loadState($relayState, 'saml:slosent');
+            $state['saml:sp:LogoutStatus'] = $message->getStatus();
+            return new RunnableResponse([Auth\Source::class, 'completeLogout'], [$state]);
+        } elseif ($message instanceof LogoutRequest) {
+            Logger::debug('module/saml2/sp/logout: Request from ' . $idpEntityId);
+            Logger::stats('saml20-idp-SLO idpinit ' . $spEntityId . ' ' . $idpEntityId);
+
+            if ($message->isNameIdEncrypted()) {
+                try {
+                    $keys = Module\saml\Message::getDecryptionKeys($idpMetadata, $spMetadata);
+                } catch (Exception $e) {
+                    throw new Error\Exception('Error decrypting NameID: ' . $e->getMessage());
+                }
+
+                $blacklist = Module\saml\Message::getBlacklistedAlgorithms($idpMetadata, $spMetadata);
+
+                $lastException = null;
+                foreach ($keys as $i => $key) {
+                    try {
+                        $message->decryptNameId($key, $blacklist);
+                        Logger::debug('Decryption with key #' . $i . ' succeeded.');
+                        $lastException = null;
+                        break;
+                    } catch (Exception $e) {
+                        Logger::debug('Decryption with key #' . $i . ' failed with exception: ' . $e->getMessage());
+                        $lastException = $e;
+                    }
+                }
+                if ($lastException !== null) {
+                    throw $lastException;
+                }
+            }
+
+            $nameId = $message->getNameId();
+            $sessionIndexes = $message->getSessionIndexes();
+
+            /** @psalm-suppress PossiblyNullArgument  This will be fixed in saml2 5.0 */
+            $numLoggedOut = Module\saml\SP\LogoutStore::logoutSessions($sourceId, $nameId, $sessionIndexes);
+            if ($numLoggedOut === false) {
+                // This type of logout was unsupported. Use the old method
+                $source->handleLogout($idpEntityId);
+                $numLoggedOut = count($sessionIndexes);
+            }
+
+            // Create and send response
+            $lr = Module\saml\Message::buildLogoutResponse($spMetadata, $idpMetadata);
+            $lr->setRelayState($message->getRelayState());
+            $lr->setInResponseTo($message->getId());
+
+            if ($numLoggedOut < count($sessionIndexes)) {
+                Logger::warning('Logged out of ' . $numLoggedOut . ' of ' . count($sessionIndexes) . ' sessions.');
+            }
+
+            $dst = $idpMetadata->getEndpointPrioritizedByBinding(
+                'SingleLogoutService',
+                [
+                    Constants::BINDING_HTTP_REDIRECT,
+                    Constants::BINDING_HTTP_POST
+                ]
+            );
+
+            if (!($binding instanceof SOAP)) {
+                $binding = Binding::getBinding($dst['Binding']);
+                if (isset($dst['ResponseLocation'])) {
+                    $dst = $dst['ResponseLocation'];
+                } else {
+                    $dst = $dst['Location'];
+                }
+                $binding->setDestination($dst);
+            } else {
+                $lr->setDestination($dst['Location']);
+            }
+
+            return new RunnableResponse([$binding, 'send'], [$lr]);
+        } else {
+            throw new Error\BadRequest('Unknown message received on logout endpoint: ' . get_class($message));
+        }
+    }
+
+
+    /**
+     * Metadata endpoint for SAML SP
+     *
+     * @param string $sourceId
+     * @return \Symfony\Component\HttpFoundation\Response|\SimpleSAML\HTTP\RunnableResponse
+     */
+    public function metadata(string $sourceId): Response
+    {
+        if ($this->config->getOptionalBoolean('admin.protectmetadata', false)) {
+            return new RunnableResponse([$this->authUtils, 'requireAdmin']);
+        }
+
+        $source = Auth\Source::getById($sourceId);
+        if ($source === null) {
+            throw new Error\AuthSource($sourceId, 'Could not find authentication source.');
+        }
+
+        if (!($source instanceof SP)) {
+            throw new Error\AuthSource(
+                $sourceId,
+                'The authentication source is not a SAML Service Provider.'
+            );
+        }
+
+        $entityId = $source->getEntityId();
+        $spconfig = $source->getMetadata();
+        $metaArray20 = $source->getHostedMetadata();
+
+        $metaBuilder = new Metadata\SAMLBuilder($entityId);
+        $metaBuilder->addMetadataSP20($metaArray20, $source->getSupportedProtocols());
+        $metaBuilder->addOrganizationInfo($metaArray20);
+
+        $xml = $metaBuilder->getEntityDescriptorText();
+
+        // sign the metadata if enabled
+        $metaxml = Metadata\Signer::sign($xml, $spconfig->toArray(), 'SAML 2 SP');
+
+        // make sure to export only the md:EntityDescriptor
+        $i = strpos($metaxml, '<md:EntityDescriptor');
+        $metaxml = substr($metaxml, $i ? $i : 0);
+
+        // 22 = strlen('</md:EntityDescriptor>')
+        $i = strrpos($metaxml, '</md:EntityDescriptor>');
+        $metaxml = substr($metaxml, 0, $i ? $i + 22 : 0);
+
+        $response = new Response();
+        $response->headers->set('Content-Type', 'application/samlmetadata+xml');
+        $response->headers->set('Content-Disposition', 'attachment; filename="' . basename($sourceId) . '.xml"');
+        $response->setContent($metaxml);
+
+        return $response;
+    }
+}
diff --git a/modules/saml/lib/IdP/SAML2.php b/modules/saml/lib/IdP/SAML2.php
index da86d9c1c..3ed49b320 100644
--- a/modules/saml/lib/IdP/SAML2.php
+++ b/modules/saml/lib/IdP/SAML2.php
@@ -255,7 +255,10 @@ class SAML2
 
         $skipEndpointValidation = false;
         if ($authnRequestSigned === true) {
-            $skipEndpointValidationWhenSigned = $spMetadata->getOptionalValue('skipEndpointValidationWhenSigned', false);
+            $skipEndpointValidationWhenSigned = $spMetadata->getOptionalValue(
+                'skipEndpointValidationWhenSigned',
+                false
+            );
             if (is_bool($skipEndpointValidationWhenSigned) === true) {
                 $skipEndpointValidation = $skipEndpointValidationWhenSigned;
             } elseif (is_callable($skipEndpointValidationWhenSigned) === true) {
diff --git a/modules/saml/routing/routes/routes.yaml b/modules/saml/routing/routes/routes.yaml
new file mode 100644
index 000000000..1361b4cdf
--- /dev/null
+++ b/modules/saml/routing/routes/routes.yaml
@@ -0,0 +1,30 @@
+saml-proxy-invalidSession:
+    path:       /proxy/invalidSession
+    defaults:   { _controller: 'SimpleSAML\Module\saml\Controller\Proxy::invalidSession' }
+saml-disco:
+    path:       /disco
+    defaults:   { _controller: 'SimpleSAML\Module\saml\Controller\Disco::disco' }
+saml-sp-discoResponse:
+    path:       /sp/discoResponse
+    defaults:   { _controller: 'SimpleSAML\Module\saml\Controller\ServiceProvider::discoResponse' }
+saml-sp-wrongAuthnContextClassRef:
+    path:       /sp/wrongAuthnContextClassRef
+    defaults:   { _controller: 'SimpleSAML\Module\saml\Controller\ServiceProvider::wrongAuthnContextClassRef' }
+saml-sp-assertionConsumerService:
+    path:       /sp/assertionConsumerService/{sourceId}
+    defaults:   { _controller: 'SimpleSAML\Module\saml\Controller\ServiceProvider::assertionConsumerService' }
+saml-sp-singleLogoutService:
+    path:       /sp/singleLogoutService/{sourceId}
+    defaults:   { _controller: 'SimpleSAML\Module\saml\Controller\ServiceProvider::singleLogoutService' }
+saml-sp-metadata:
+    path:       /sp/metadata/{sourceId}
+    defaults:   { _controller: 'SimpleSAML\Module\saml\Controller\ServiceProvider::metadata' }
+saml-legacy-sp-assertionConsumerService:
+    path:       /sp/saml2-acs.php/{sourceId}
+    defaults:   { _controller: 'SimpleSAML\Module\saml\Controller\ServiceProvider::assertionConsumerService', path: /saml/sp/assertionConsumerService, permanent: true }
+saml-legacy-sp-singleLogoutService:
+    path:       /sp/saml2-logout.php/{sourceId}
+    defaults:   { _controller: 'SimpleSAML\Module\saml\Controller\ServiceProvider::singleLogoutService', path: /saml/sp/singleLogoutService, permanent: true }
+saml-legacy-sp-metadata:
+    path:       /sp/metadata.php/{sourceId}
+    defaults:   { _controller: 'SimpleSAML\Module\saml\Controller\ServiceProvider::metadata', path: /saml/sp/metadata, permanent: true }
diff --git a/modules/saml/www/disco.php b/modules/saml/www/disco.php
deleted file mode 100644
index 93d8f68bb..000000000
--- a/modules/saml/www/disco.php
+++ /dev/null
@@ -1,8 +0,0 @@
-<?php
-
-/**
- * Built-in IdP discovery service.
- */
-
-$discoHandler = new \SimpleSAML\XHTML\IdPDisco(['saml20-idp-remote'], 'saml');
-$discoHandler->handleRequest();
diff --git a/modules/saml/www/proxy/invalid_session.php b/modules/saml/www/proxy/invalid_session.php
deleted file mode 100644
index 5181da60e..000000000
--- a/modules/saml/www/proxy/invalid_session.php
+++ /dev/null
@@ -1,56 +0,0 @@
-<?php
-
-/**
- * This file will handle the case of a user with an existing session that's not valid for a specific Service Provider,
- * since the authenticating IdP is not in the list of IdPs allowed by the SP.
- *
- *
- * @package SimpleSAMLphp
- */
-
-// retrieve the authentication state
-if (!array_key_exists('AuthState', $_REQUEST)) {
-    throw new \SimpleSAML\Error\BadRequest('Missing mandatory parameter: AuthState');
-}
-
-try {
-    // try to get the state
-    $state = \SimpleSAML\Auth\State::loadState($_REQUEST['AuthState'], 'saml:proxy:invalid_idp');
-} catch (\Exception $e) {
-    // the user probably hit the back button after starting the logout, try to recover the state with another stage
-    $state = \SimpleSAML\Auth\State::loadState($_REQUEST['AuthState'], 'core:Logout:afterbridge');
-
-    // success! Try to continue with reauthentication, since we no longer have a valid session here
-    $idp = \SimpleSAML\IdP::getById($state['core:IdP']);
-    \SimpleSAML\Module\saml\Auth\Source\SP::reauthPostLogout($idp, $state);
-}
-
-if (isset($_POST['cancel'])) {
-    // the user does not want to logout, cancel login
-    \SimpleSAML\Auth\State::throwException(
-        $state,
-        new \SimpleSAML\Module\saml\Error\NoAvailableIDP(
-            \SAML2\Constants::STATUS_RESPONDER,
-            'User refused to reauthenticate with any of the IdPs requested.'
-        )
-    );
-}
-
-if (isset($_POST['continue'])) {
-    /** @var \SimpleSAML\Module\saml\Auth\Source\SP $as */
-    $as = \SimpleSAML\Auth\Source::getById($state['saml:sp:AuthId'], '\SimpleSAML\Module\saml\Auth\Source\SP');
-
-    // log the user out before being able to login again
-    $as->reauthLogout($state);
-}
-
-$cfg = \SimpleSAML\Configuration::getInstance();
-$template = new \SimpleSAML\XHTML\Template($cfg, 'saml:proxy/invalid_session.twig');
-$template->data['AuthState'] = (string) $_REQUEST['AuthState'];
-
-$idpmdcfg = $state['saml:sp:IdPMetadata'];
-/** @var \SimpleSAML\Configuration $idpmdcfg */
-$template->data['entity_idp'] = $idpmdcfg->toArray();
-$template->data['entity_sp'] = $state['SPMetadata'];
-
-$template->send();
diff --git a/modules/saml/www/sp/discoresp.php b/modules/saml/www/sp/discoresp.php
deleted file mode 100644
index 5944cfae5..000000000
--- a/modules/saml/www/sp/discoresp.php
+++ /dev/null
@@ -1,33 +0,0 @@
-<?php
-
-/**
- * Handler for response from IdP discovery service.
- */
-
-use SimpleSAML\Assert\Assert;
-use SimpleSAML\Auth;
-use SimpleSAML\Error;
-
-if (!array_key_exists('AuthID', $_REQUEST)) {
-    throw new Error\BadRequest('Missing AuthID to discovery service response handler');
-}
-
-if (!array_key_exists('idpentityid', $_REQUEST)) {
-    throw new Error\BadRequest('Missing idpentityid to discovery service response handler');
-}
-
-$state = Auth\State::loadState($_REQUEST['AuthID'], 'saml:sp:sso');
-
-// Find authentication source
-Assert::keyExists($state, 'saml:sp:AuthId');
-$sourceId = $state['saml:sp:AuthId'];
-
-$source = Auth\Source::getById($sourceId);
-if ($source === null) {
-    throw new Exception('Could not find authentication source with id ' . $sourceId);
-}
-if (!($source instanceof \SimpleSAML\Module\saml\Auth\Source\SP)) {
-    throw new Error\Exception('Source type changed?');
-}
-
-$source->startSSO($_REQUEST['idpentityid'], $state);
diff --git a/modules/saml/www/sp/metadata.php b/modules/saml/www/sp/metadata.php
deleted file mode 100644
index 4992710fb..000000000
--- a/modules/saml/www/sp/metadata.php
+++ /dev/null
@@ -1,51 +0,0 @@
-<?php
-
-use SimpleSAML\Auth;
-use SimpleSAML\Configuration;
-use SimpleSAML\Error;
-use SimpleSAML\Metadata;
-use SimpleSAML\Module;
-use SimpleSAML\Store\StoreFactory;
-use SimpleSAML\Utils;
-
-if (!array_key_exists('PATH_INFO', $_SERVER)) {
-    throw new Error\BadRequest('Missing authentication source id in metadata URL');
-}
-
-$config = Configuration::getInstance();
-if ($config->getOptionalBoolean('admin.protectmetadata', false)) {
-    $authUtils = new Utils\Auth();
-    $authUtils->requireAdmin();
-}
-$sourceId = substr($_SERVER['PATH_INFO'], 1);
-$source = Auth\Source::getById($sourceId);
-if ($source === null) {
-    throw new Error\AuthSource($sourceId, 'Could not find authentication source.');
-}
-
-if (!($source instanceof Module\saml\Auth\Source\SP)) {
-    throw new Error\AuthSource(
-        $sourceId,
-        'The authentication source is not a SAML Service Provider.'
-    );
-}
-
-$entityId = $source->getEntityId();
-$spconfig = $source->getMetadata();
-$metaArray20 = $source->getHostedMetadata();
-
-$storeType = $config->getOptionalString('store.type', 'phpsession');
-$store = StoreFactory::getInstance($storeType);
-
-$metaBuilder = new Metadata\SAMLBuilder($entityId);
-$metaBuilder->addMetadataSP20($metaArray20, $source->getSupportedProtocols());
-$metaBuilder->addOrganizationInfo($metaArray20);
-
-$xml = $metaBuilder->getEntityDescriptorText();
-
-// sign the metadata if enabled
-$xml = Metadata\Signer::sign($xml, $spconfig->toArray(), 'SAML 2 SP');
-
-header('Content-Type: application/samlmetadata+xml');
-header('Content-Disposition: attachment; filename="' . basename($sourceId) . '.xml"');
-echo($xml);
diff --git a/modules/saml/www/sp/saml2-acs.php b/modules/saml/www/sp/saml2-acs.php
deleted file mode 100644
index 091a65781..000000000
--- a/modules/saml/www/sp/saml2-acs.php
+++ /dev/null
@@ -1,280 +0,0 @@
-<?php
-
-/**
- * Assertion consumer service handler for SAML 2.0 SP authentication client.
- */
-
-use SAML2\Binding;
-use SAML2\Assertion;
-use SAML2\Exception\Protocol\UnsupportedBindingException;
-use SAML2\HTTPArtifact;
-use SAML2\Response;
-use SimpleSAML\Assert\Assert;
-use SimpleSAML\Auth;
-use SimpleSAML\Configuration;
-use SimpleSAML\Error;
-use SimpleSAML\Module;
-use SimpleSAML\Logger;
-use SimpleSAML\Session;
-use SimpleSAML\Store\StoreFactory;
-use SimpleSAML\Utils;
-
-if (!array_key_exists('PATH_INFO', $_SERVER)) {
-    throw new Error\BadRequest('Missing authentication source ID in assertion consumer service URL');
-}
-
-$sourceId = substr($_SERVER['PATH_INFO'], 1);
-
-/** @var \SimpleSAML\Module\saml\Auth\Source\SP $source */
-$source = Auth\Source::getById($sourceId, '\SimpleSAML\Module\saml\Auth\Source\SP');
-
-$spMetadata = $source->getMetadata();
-try {
-    $b = Binding::getCurrentBinding();
-} catch (UnsupportedBindingException $e) {
-    throw new Error\Error('ACSPARAMS', $e, 400);
-}
-
-if ($b instanceof HTTPArtifact) {
-    $b->setSPMetadata($spMetadata);
-}
-
-$response = $b->receive();
-if (!($response instanceof Response)) {
-    throw new Error\BadRequest('Invalid message received at AssertionConsumerService endpoint.');
-}
-
-$issuer = $response->getIssuer();
-if ($issuer === null) {
-    // no Issuer in the response. Look for an unencrypted assertion with an issuer
-    foreach ($response->getAssertions() as $a) {
-        if ($a instanceof Assertion) {
-            // we found an unencrypted assertion, there should be an issuer here
-            $issuer = $a->getIssuer();
-            break;
-        }
-    }
-    if ($issuer === null) {
-        // no issuer found in the assertions
-        throw new Exception('Missing <saml:Issuer> in message delivered to AssertionConsumerService.');
-    }
-}
-$issuer = $issuer->getValue();
-
-$session = Session::getSessionFromRequest();
-$prevAuth = $session->getAuthData($sourceId, 'saml:sp:prevAuth');
-
-$httpUtils = new Utils\HTTP();
-if ($prevAuth !== null && $prevAuth['id'] === $response->getId() && $prevAuth['issuer'] === $issuer) {
-    /* OK, it looks like this message has the same issuer
-     * and ID as the SP session we already have active. We
-     * therefore assume that the user has somehow triggered
-     * a resend of the message.
-     * In that case we may as well just redo the previous redirect
-     * instead of displaying a confusing error message.
-     */
-    Logger::info(
-        'Duplicate SAML 2 response detected - ignoring the response and redirecting the user to the correct page.'
-    );
-    if (isset($prevAuth['redirect'])) {
-        $httpUtils->redirectTrustedURL($prevAuth['redirect']);
-    }
-
-    Logger::info('No RelayState or ReturnURL available, cannot redirect.');
-    throw new Error\Exception('Duplicate assertion received.');
-}
-
-$idpMetadata = null;
-$state = null;
-$stateId = $response->getInResponseTo();
-
-if (!empty($stateId)) {
-    // this should be a response to a request we sent earlier
-    try {
-        $state = Auth\State::loadState($stateId, 'saml:sp:sso');
-    } catch (Exception $e) {
-        // something went wrong,
-        Logger::warning(sprintf(
-            'Could not load state specified by InResponseTo: %s Processing response as unsolicited.',
-            $e->getMessage(),
-        ));
-    }
-}
-
-$enableUnsolicited = $spMetadata->getOptionalBoolean('enable_unsolicited', true);
-if ($state === null && $enableUnsolicited === false) {
-    throw new Error\BadRequest('Unsolicited responses are denied by configuration.');
-}
-
-if ($state) {
-    // check that the authentication source is correct
-    Assert::keyExists($state, 'saml:sp:AuthId');
-    if ($state['saml:sp:AuthId'] !== $sourceId) {
-        throw new Error\Exception(
-            'The authentication source id in the URL does not match the authentication source which sent the request.'
-        );
-    }
-
-    // check that the issuer is the one we are expecting
-    Assert::keyExists($state, 'ExpectedIssuer');
-    if ($state['ExpectedIssuer'] !== $issuer) {
-        $idpMetadata = $source->getIdPMetadata($issuer);
-        $idplist = $idpMetadata->getOptionalArrayize('IDPList', []);
-        if (!in_array($state['ExpectedIssuer'], $idplist, true)) {
-            Logger::warning(
-                'The issuer of the response not match to the identity provider we sent the request to.'
-            );
-        }
-    }
-} else {
-    // this is an unsolicited response
-    $relaystate = $spMetadata->getOptionalString('RelayState', $response->getRelayState());
-    $state = [
-        'saml:sp:isUnsolicited' => true,
-        'saml:sp:AuthId'        => $sourceId,
-        'saml:sp:RelayState'    => $relaystate === null ? null : $httpUtils->checkURLAllowed($relaystate),
-    ];
-}
-
-Logger::debug('Received SAML2 Response from ' . var_export($issuer, true) . '.');
-
-if (is_null($idpMetadata)) {
-    $idpMetadata = $source->getIdPmetadata($issuer);
-}
-
-try {
-    $assertions = Module\saml\Message::processResponse($spMetadata, $idpMetadata, $response);
-} catch (Module\saml\Error $e) {
-    // the status of the response wasn't "success"
-    $e = $e->toException();
-    Auth\State::throwException($state, $e);
-    return;
-}
-
-$authenticatingAuthority = null;
-$nameId = null;
-$sessionIndex = null;
-$expire = null;
-$attributes = [];
-$foundAuthnStatement = false;
-
-$config = Configuration::getInstance();
-$storeType = $config->getOptionalString('store.type', 'phpsession');
-
-$store = StoreFactory::getInstance($storeType);
-
-foreach ($assertions as $assertion) {
-    // check for duplicate assertion (replay attack)
-    if ($store !== false) {
-        $aID = $assertion->getId();
-        if ($store->get('saml.AssertionReceived', $aID) !== null) {
-            $e = new Error\Exception('Received duplicate assertion.');
-            Auth\State::throwException($state, $e);
-        }
-
-        $notOnOrAfter = $assertion->getNotOnOrAfter();
-        if ($notOnOrAfter === null) {
-            $notOnOrAfter = time() + 24 * 60 * 60;
-        } else {
-            $notOnOrAfter += 60; // we allow 60 seconds clock skew, so add it here also
-        }
-
-        $store->set('saml.AssertionReceived', $aID, true, $notOnOrAfter);
-    }
-
-    if ($authenticatingAuthority === null) {
-        $authenticatingAuthority = $assertion->getAuthenticatingAuthority();
-    }
-    if ($nameId === null) {
-        $nameId = $assertion->getNameId();
-    }
-    if ($sessionIndex === null) {
-        $sessionIndex = $assertion->getSessionIndex();
-    }
-    if ($expire === null) {
-        $expire = $assertion->getSessionNotOnOrAfter();
-    }
-
-    $attributes = array_merge($attributes, $assertion->getAttributes());
-
-    if ($assertion->getAuthnInstant() !== null) {
-        // assertion contains AuthnStatement, since AuthnInstant is a required attribute
-        $foundAuthnStatement = true;
-    }
-}
-$assertion = end($assertions);
-
-if (!$foundAuthnStatement) {
-    $e = new Error\Exception('No AuthnStatement found in assertion(s).');
-    Auth\State::throwException($state, $e);
-}
-
-if ($expire !== null) {
-    $logoutExpire = $expire;
-} else {
-    // just expire the logout association 24 hours into the future
-    $logoutExpire = time() + 24 * 60 * 60;
-}
-
-if (!empty($nameId)) {
-    // register this session in the logout store
-    Module\saml\SP\LogoutStore::addSession($sourceId, $nameId, $sessionIndex, $logoutExpire);
-
-    // we need to save the NameID and SessionIndex for logout
-    $logoutState = [
-        'saml:logout:Type'         => 'saml2',
-        'saml:logout:IdP'          => $issuer,
-        'saml:logout:NameID'       => $nameId,
-        'saml:logout:SessionIndex' => $sessionIndex,
-    ];
-
-    $state['saml:sp:NameID'] = $nameId; // no need to mark it as persistent, it already is
-} else {
-    /*
-     * No NameID provided, we can't logout from this IdP!
-     *
-     * Even though interoperability profiles "require" a NameID, the SAML 2.0 standard does not require it to be present
-     * in assertions. That way, we could have a Subject with only a SubjectConfirmation, or even no Subject element at
-     * all.
-     *
-     * In case we receive a SAML assertion with no NameID, we can be graceful and continue, but we won't be able to
-     * perform a Single Logout since the SAML logout profile mandates the use of a NameID to identify the individual we
-     * want to be logged out. In order to minimize the impact of this, we keep logout state information (without saving
-     * it to the store), marking the IdP as SAML 1.0, which does not implement logout. Then we can safely log the user
-     * out from the local session, skipping Single Logout upstream to the IdP.
-     */
-    $logoutState = [
-        'saml:logout:Type'         => 'saml1',
-    ];
-}
-
-$state['LogoutState'] = $logoutState;
-$state['saml:AuthenticatingAuthority'] = $authenticatingAuthority;
-$state['saml:AuthenticatingAuthority'][] = $issuer;
-$state['PersistentAuthData'][] = 'saml:AuthenticatingAuthority';
-$state['saml:AuthnInstant'] = $assertion->getAuthnInstant();
-$state['PersistentAuthData'][] = 'saml:AuthnInstant';
-$state['saml:sp:SessionIndex'] = $sessionIndex;
-$state['PersistentAuthData'][] = 'saml:sp:SessionIndex';
-$state['saml:sp:AuthnContext'] = $assertion->getAuthnContextClassRef();
-$state['PersistentAuthData'][] = 'saml:sp:AuthnContext';
-
-if ($expire !== null) {
-    $state['Expire'] = $expire;
-}
-
-// note some information about the authentication, in case we receive the same response again
-$state['saml:sp:prevAuth'] = [
-    'id'     => $response->getId(),
-    'issuer' => $issuer,
-    'inResponseTo' => $response->getInResponseTo(),
-];
-if (isset($state['\SimpleSAML\Auth\Source.ReturnURL'])) {
-    $state['saml:sp:prevAuth']['redirect'] = $state['\SimpleSAML\Auth\Source.ReturnURL'];
-} elseif (isset($state['saml:sp:RelayState'])) {
-    $state['saml:sp:prevAuth']['redirect'] = $state['saml:sp:RelayState'];
-}
-$state['PersistentAuthData'][] = 'saml:sp:prevAuth';
-
-$source->handleResponse($state, $issuer, $attributes);
-Assert::true(false);
diff --git a/modules/saml/www/sp/saml2-logout.php b/modules/saml/www/sp/saml2-logout.php
deleted file mode 100644
index 29478862a..000000000
--- a/modules/saml/www/sp/saml2-logout.php
+++ /dev/null
@@ -1,160 +0,0 @@
-<?php
-
-/**
- * Logout endpoint handler for SAML SP authentication client.
- *
- * This endpoint handles both logout requests and logout responses.
- */
-
-use Exception;
-use SAML2\Binding;
-use SAML2\Constants;
-use SAML2\Exception\Protocol\UnsupportedBindingException;
-use SAML2\LogoutResponse;
-use SAML2\LogoutRequest;
-use SAML2\SOAP;
-use SAML2\XML\saml\Issuer;
-use SimpleSAML\Auth;
-use SimpleSAML\Error;
-use SimpleSAML\Logger;
-use SimpleSAML\Metadata;
-use SimpleSAML\Module;
-use SimpleSAML\Utils;
-
-if (!array_key_exists('PATH_INFO', $_SERVER)) {
-    throw new Error\BadRequest('Missing authentication source ID in logout URL');
-}
-
-$sourceId = substr($_SERVER['PATH_INFO'], 1);
-
-/** @var \SimpleSAML\Module\saml\Auth\Source\SP $source */
-$source = Auth\Source::getById($sourceId);
-if ($source === null) {
-    throw new Exception('Could not find authentication source with id ' . $sourceId);
-} elseif (!($source instanceof \SimpleSAML\Module\saml\Auth\Source\SP)) {
-    throw new Error\Exception('Source type changed?');
-}
-
-try {
-    $binding = Binding::getCurrentBinding();
-} catch (UnsupportedBindingException $e) {
-    throw new Error\Error('SLOSERVICEPARAMS', $e, 400);
-}
-$message = $binding->receive();
-
-$issuer = $message->getIssuer();
-if ($issuer instanceof Issuer) {
-    $idpEntityId = $issuer->getValue();
-} else {
-    $idpEntityId = $issuer;
-}
-
-if ($idpEntityId === null) {
-    // Without an issuer we have no way to respond to the message.
-    throw new Error\BadRequest('Received message on logout endpoint without issuer.');
-}
-
-$spEntityId = $source->getEntityId();
-
-$metadata = Metadata\MetaDataStorageHandler::getMetadataHandler();
-$idpMetadata = $source->getIdPMetadata($idpEntityId);
-$spMetadata = $source->getMetadata();
-
-Module\saml\Message::validateMessage($idpMetadata, $spMetadata, $message);
-
-$httpUtils = new Utils\HTTP();
-$destination = $message->getDestination();
-if ($destination !== null && $destination !== $httpUtils->getSelfURLNoQuery()) {
-    throw new Error\Exception('Destination in logout message is wrong.');
-}
-
-if ($message instanceof LogoutResponse) {
-    $relayState = $message->getRelayState();
-    if ($relayState === null) {
-        // Somehow, our RelayState has been lost.
-        throw new Error\BadRequest('Missing RelayState in logout response.');
-    }
-
-    if (!$message->isSuccess()) {
-        Logger::warning(
-            'Unsuccessful logout. Status was: ' . Module\saml\Message::getResponseError($message)
-        );
-    }
-
-    $state = Auth\State::loadState($relayState, 'saml:slosent');
-    $state['saml:sp:LogoutStatus'] = $message->getStatus();
-    \SimpleSAML\Auth\Source::completeLogout($state);
-} elseif ($message instanceof LogoutRequest) {
-    Logger::debug('module/saml2/sp/logout: Request from ' . $idpEntityId);
-    Logger::stats('saml20-idp-SLO idpinit ' . $spEntityId . ' ' . $idpEntityId);
-
-    if ($message->isNameIdEncrypted()) {
-        try {
-            $keys = Module\saml\Message::getDecryptionKeys($idpMetadata, $spMetadata);
-        } catch (Exception $e) {
-            throw new Error\Exception('Error decrypting NameID: ' . $e->getMessage());
-        }
-
-        $blacklist = Module\saml\Message::getBlacklistedAlgorithms($idpMetadata, $spMetadata);
-
-        $lastException = null;
-        foreach ($keys as $i => $key) {
-            try {
-                $message->decryptNameId($key, $blacklist);
-                Logger::debug('Decryption with key #' . $i . ' succeeded.');
-                $lastException = null;
-                break;
-            } catch (Exception $e) {
-                Logger::debug('Decryption with key #' . $i . ' failed with exception: ' . $e->getMessage());
-                $lastException = $e;
-            }
-        }
-        if ($lastException !== null) {
-            throw $lastException;
-        }
-    }
-
-    $nameId = $message->getNameId();
-    $sessionIndexes = $message->getSessionIndexes();
-
-    /** @psalm-suppress PossiblyNullArgument  This will be fixed in saml2 5.0 */
-    $numLoggedOut = Module\saml\SP\LogoutStore::logoutSessions($sourceId, $nameId, $sessionIndexes);
-    if ($numLoggedOut === false) {
-        // This type of logout was unsupported. Use the old method
-        $source->handleLogout($idpEntityId);
-        $numLoggedOut = count($sessionIndexes);
-    }
-
-    // Create and send response
-    $lr = Module\saml\Message::buildLogoutResponse($spMetadata, $idpMetadata);
-    $lr->setRelayState($message->getRelayState());
-    $lr->setInResponseTo($message->getId());
-
-    if ($numLoggedOut < count($sessionIndexes)) {
-        Logger::warning('Logged out of ' . $numLoggedOut . ' of ' . count($sessionIndexes) . ' sessions.');
-    }
-
-    $dst = $idpMetadata->getEndpointPrioritizedByBinding(
-        'SingleLogoutService',
-        [
-            Constants::BINDING_HTTP_REDIRECT,
-            Constants::BINDING_HTTP_POST
-        ]
-    );
-
-    if (!($binding instanceof SOAP)) {
-        $binding = Binding::getBinding($dst['Binding']);
-        if (isset($dst['ResponseLocation'])) {
-            $dst = $dst['ResponseLocation'];
-        } else {
-            $dst = $dst['Location'];
-        }
-        $binding->setDestination($dst);
-    } else {
-        $lr->setDestination($dst['Location']);
-    }
-
-    $binding->send($lr);
-} else {
-    throw new Error\BadRequest('Unknown message received on logout endpoint: ' . get_class($message));
-}
diff --git a/modules/saml/www/sp/wrong_authncontextclassref.php b/modules/saml/www/sp/wrong_authncontextclassref.php
deleted file mode 100644
index da2ed84f9..000000000
--- a/modules/saml/www/sp/wrong_authncontextclassref.php
+++ /dev/null
@@ -1,8 +0,0 @@
-<?php
-
-use SimpleSAML\Configuration;
-use SimpleSAML\XHTML\Template;
-
-$globalConfig = Configuration::getInstance();
-$t = new Template($globalConfig, 'saml:sp/wrong_authncontextclassref.twig');
-$t->send();
diff --git a/phpcs.xml b/phpcs.xml
index 5f39d24af..012ba6be0 100644
--- a/phpcs.xml
+++ b/phpcs.xml
@@ -16,10 +16,8 @@
     <file>www</file>
 
     <exclude-pattern>modules/adfs/*</exclude-pattern>
-    <exclude-pattern>www/assets/css/stylesheet.css</exclude-pattern>
-    <exclude-pattern>www/assets/js/bundle.js</exclude-pattern>
-    <exclude-pattern>www/assets/js/logout.js</exclude-pattern>
-    <exclude-pattern>www/assets/js/stylesheet.js</exclude-pattern>
+    <exclude-pattern>www/assets/css/*</exclude-pattern>
+    <exclude-pattern>www/assets/js/*</exclude-pattern>
 
     <!-- This is the rule we inherit from. If you want to exlude some specific rules, see the docs on how to do that -->
     <rule ref="PSR12"/>
@@ -34,6 +32,7 @@
         <!-- Exclude files with long lines that we cannot immediately fix -->
         <exclude-pattern>tests/lib/SimpleSAML/Metadata/MetaDataStorageSourceTest.php</exclude-pattern>
         <exclude-pattern>tests/lib/SimpleSAML/Metadata/SAMLParserTest.php</exclude-pattern>
+        <exclude-pattern>tests/modules/saml/lib/Controller/ServiceProviderTest.php</exclude-pattern>
     </rule>
 
     <!-- Ignore files with side effects that we cannot fix -->
diff --git a/psalm.xml b/psalm.xml
index 2d2bee768..c03a78417 100644
--- a/psalm.xml
+++ b/psalm.xml
@@ -63,7 +63,6 @@
         <MissingFile>
             <errorLevel type="suppress">
                 <file name="www/*.php" />
-                <file name="modules/*/www/*.php" />
             </errorLevel>
         </MissingFile>
 
diff --git a/tests/modules/saml/lib/Auth/Source/SPTest.php b/tests/modules/saml/lib/Auth/Source/SPTest.php
index 0387ae082..53559002e 100644
--- a/tests/modules/saml/lib/Auth/Source/SPTest.php
+++ b/tests/modules/saml/lib/Auth/Source/SPTest.php
@@ -459,7 +459,7 @@ class SPTest extends ClearStateTestCase
         $this->assertIsArray($md['AssertionConsumerService']);
         foreach ($md['AssertionConsumerService'] as $acs) {
             $this->assertEquals(
-                'http://localhost/simplesaml/module.php/saml/sp/saml2-acs.php/' . $spId,
+                'http://localhost/simplesaml/module.php/saml/sp/assertionConsumerService/' . $spId,
                 $acs['Location']
             );
             $this->assertStringStartsWith('urn:oasis:names:tc:SAML:2.0:bindings', $acs['Binding']);
@@ -1198,7 +1198,10 @@ class SPTest extends ClearStateTestCase
         $hok = $md['AssertionConsumerService'][2];
         $this->assertIsArray($hok);
         $this->assertEquals('urn:oasis:names:tc:SAML:2.0:profiles:holder-of-key:SSO:browser', $hok['Binding']);
-        $this->assertEquals('http://localhost/simplesaml/module.php/saml/sp/saml2-acs.php/' . $spId, $hok['Location']);
+        $this->assertEquals(
+            'http://localhost/simplesaml/module.php/saml/sp/assertionConsumerService/' . $spId,
+            $hok['Location']
+        );
         $this->assertEquals(2, $hok['index']);
         $this->assertEquals('urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', $hok['hoksso:ProtocolBinding']);
     }
diff --git a/tests/modules/saml/lib/Controller/DiscoTest.php b/tests/modules/saml/lib/Controller/DiscoTest.php
new file mode 100644
index 000000000..a5311d3f8
--- /dev/null
+++ b/tests/modules/saml/lib/Controller/DiscoTest.php
@@ -0,0 +1,67 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\saml\Controller;
+
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Configuration;
+use SimpleSAML\Http\RunnableResponse;
+use SimpleSAML\Module\saml\Controller;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Set of tests for the controllers in the "saml" module.
+ *
+ * @covers \SimpleSAML\Module\saml\Controller\Disco
+ * @package SimpleSAML\Test
+ */
+class DiscoTest extends TestCase
+{
+    /** @var \SimpleSAML\Configuration */
+    protected Configuration $config;
+
+
+    /**
+     * Set up for each test.
+     */
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->config = Configuration::loadFromArray(
+            [
+                'module.enable' => ['saml' => true],
+            ],
+            '[ARRAY]',
+            'simplesaml'
+        );
+        Configuration::setPreLoadedConfig($this->config, 'config.php');
+    }
+
+
+    /**
+     * Test that accessing the disco-endpoint leads to a RunnableResponse
+     *
+     * @return void
+     */
+    public function testDisco(): void
+    {
+        $params = [
+            'entityID' => 'urn:entity:phpunit',
+            'return' => '/something',
+            'isPassive' => 'true',
+            'IdPentityID' => 'some:idp:phpunit',
+            'returnIDParam' => 'someParam',
+            'IDPList' => 'a,b,c',
+        ];
+
+        $_GET = array_merge($_GET, $params);
+        $_SERVER['REQUEST_URI'] = '/disco';
+
+        $c = new Controller\Disco($this->config);
+
+        $result = $c->disco();
+        $this->assertInstanceOf(RunnableResponse::class, $result);
+    }
+}
diff --git a/tests/modules/saml/lib/Controller/ProxyTest.php b/tests/modules/saml/lib/Controller/ProxyTest.php
new file mode 100644
index 000000000..7ebfdf4ad
--- /dev/null
+++ b/tests/modules/saml/lib/Controller/ProxyTest.php
@@ -0,0 +1,165 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\saml\Controller;
+
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Auth;
+use SimpleSAML\Configuration;
+use SimpleSAML\Error;
+use SimpleSAML\Http\RunnableResponse;
+use SimpleSAML\Module\saml\Controller;
+use SimpleSAML\Module\saml\Error\NoAvailableIDP;
+use SimpleSAML\XHTML\Template;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Set of tests for the controllers in the "saml" module.
+ *
+ * @covers \SimpleSAML\Module\saml\Controller\Proxy
+ * @package SimpleSAML\Test
+ */
+class ProxyTest extends TestCase
+{
+    /** @var \SimpleSAML\Configuration */
+    protected Configuration $config;
+
+
+    /**
+     * Set up for each test.
+     */
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->config = Configuration::loadFromArray(
+            [
+                'module.enable' => ['saml' => true],
+            ],
+            '[ARRAY]',
+            'simplesaml'
+        );
+        Configuration::setPreLoadedConfig($this->config, 'config.php');
+
+        Configuration::setPreLoadedConfig(
+            Configuration::loadFromArray(
+                [
+                    'phpunit' => ['saml:SP'],
+                ],
+                '[ARRAY]',
+                'simplesaml'
+            ),
+            'authsources.php',
+            'simplesaml'
+        );
+    }
+
+
+    /**
+     * Test that accessing the invalidSession-endpoint without StateId leads to an exception
+     *
+     * @return void
+     */
+    public function testMissingStateId(): void
+    {
+        $request = Request::create(
+            '/invalidSesssion',
+            'POST',
+        );
+
+        $c = new Controller\Proxy($this->config);
+
+        $this->expectException(Error\BadRequest::class);
+        $this->expectExceptionMessage('Missing mandatory parameter: AuthState');
+
+        $c->invalidSession($request);
+    }
+
+
+    /**
+     * Test that accessing the invalidSession-endpoint with StateId results in a Template
+     *
+     * @return void
+     */
+    public function testWithStateId(): void
+    {
+        $request = Request::create(
+            '/invalidSesssion?AuthState=someState',
+            'POST',
+        );
+
+        $c = new Controller\Proxy($this->config);
+        $c->setAuthState(new class () extends Auth\State {
+            public static function loadState(string $id, string $stage, bool $allowMissing = false): ?array
+            {
+                return [
+                    'saml:sp:IdPMetadata' => Configuration::loadFromArray(['test' => 'phpunit']),
+                    'SPMetadata' => 'something else',
+                ];
+            }
+        });
+
+        $result = $c->invalidSession($request);
+        $this->assertInstanceOf(Template::class, $result);
+    }
+
+
+    /**
+     * Test that accessing the invalidSession-endpoint with StateId and
+     * with pressing cancel results in a RunnableResponse
+     *
+     * @return void
+     */
+    public function testWithStateIdCancel(): void
+    {
+        $request = Request::create(
+            '/invalidSesssion?AuthState=someState',
+            'POST',
+            ['cancel' => 'cancel'],
+        );
+
+        $c = new Controller\Proxy($this->config);
+        $c->setAuthState(new class () extends Auth\State {
+            public static function loadState(string $id, string $stage, bool $allowMissing = false): ?array
+            {
+                return [
+                    'saml:sp:IdPMetadata' => Configuration::loadFromArray(['test' => 'phpunit']),
+                    'SPMetadata' => 'something else',
+                ];
+            }
+        });
+
+        $this->expectException(NoAvailableIDP::class);
+        $c->invalidSession($request);
+    }
+
+
+    /**
+     * Test that accessing the invalidSession-endpoint with StateId and
+     * with pressing continue results in a RunnableResponse
+     *
+     * @return void
+     */
+    public function testWithStateIdContinue(): void
+    {
+        $request = Request::create(
+            '/invalidSesssion?AuthState=someState',
+            'POST',
+            ['continue' => 'continue'],
+        );
+
+        $c = new Controller\Proxy($this->config);
+        $c->setAuthState(new class () extends Auth\State {
+            public static function loadState(string $id, string $stage, bool $allowMissing = false): ?array
+            {
+                return [
+                    'saml:sp:AuthId' => 'phpunit',
+                ];
+            }
+        });
+
+        $result = $c->invalidSession($request);
+        $this->assertInstanceOf(RunnableResponse::class, $result);
+    }
+}
diff --git a/tests/modules/saml/lib/Controller/ServiceProviderTest.php b/tests/modules/saml/lib/Controller/ServiceProviderTest.php
new file mode 100644
index 000000000..dd9b130f3
--- /dev/null
+++ b/tests/modules/saml/lib/Controller/ServiceProviderTest.php
@@ -0,0 +1,438 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\saml\Controller;
+
+use Exception;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Auth;
+use SimpleSAML\Configuration;
+use SimpleSAML\Error;
+use SimpleSAML\Http\RunnableResponse;
+use SimpleSAML\Module\saml\Controller;
+use SimpleSAML\Session;
+use SimpleSAML\Utils;
+use SimpleSAML\XHTML\Template;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Set of tests for the controllers in the "saml" module.
+ *
+ * @covers \SimpleSAML\Module\saml\Controller\ServiceProvider
+ * @package SimpleSAML\Test
+ */
+class ServiceProviderTest extends TestCase
+{
+    /** @var \SimpleSAML\Configuration */
+    protected Configuration $config;
+
+    /** @var \SimpleSAML\Session */
+    protected Session $session;
+
+    /** @var \SimpleSAML\Utils\Auth */
+    protected Utils\Auth $authUtils;
+
+
+    /**
+     * Set up for each test.
+     */
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->session = Session::getSessionFromRequest();
+        $this->config = Configuration::loadFromArray(
+            [
+                'module.enable' => ['saml' => true],
+                'admin.protectmetadata' => true,
+            ],
+            '[ARRAY]',
+            'simplesaml'
+        );
+        Configuration::setPreLoadedConfig($this->config, 'config.php');
+
+        Configuration::setPreLoadedConfig(
+            Configuration::loadFromArray(
+                [
+                    'admin' => ['core:AdminPassword'],
+                    'phpunit' => ['saml:SP'],
+                ],
+                '[ARRAY]',
+                'simplesaml'
+            ),
+            'authsources.php',
+            'simplesaml'
+        );
+
+        $this->authUtils = new class () extends Utils\Auth {
+            public function requireAdmin(): void
+            {
+                // stub
+            }
+        };
+    }
+
+
+    /**
+     * Test that accessing the discoResponse-endpoint without AuthID leads to an exception
+     *
+     * @return void
+     */
+    public function testDiscoResponseMissingAuthId(): void
+    {
+        $request = Request::create(
+            '/discoResponse',
+            'GET',
+        );
+
+        $c = new Controller\ServiceProvider($this->config, $this->session);
+
+        $this->expectException(Error\BadRequest::class);
+        $this->expectExceptionMessage('Missing AuthID to discovery service response handler');
+
+        $c->discoResponse($request);
+    }
+
+
+    /**
+     * Test that accessing the discoResponse-endpoint with AuthID but without idpentityid results in an exception
+     *
+     * @return void
+     */
+    public function testWithAuthIdWithoutEntity(): void
+    {
+        $request = Request::create(
+            '/discoResponse',
+            'GET',
+            ['AuthID' => 'abc123']
+        );
+
+        $c = new Controller\ServiceProvider($this->config, $this->session);
+
+        $this->expectException(Error\BadRequest::class);
+        $this->expectExceptionMessage('Missing idpentityid to discovery service response handler');
+
+        $c->discoResponse($request);
+    }
+
+
+    /**
+     * Test that accessing the discoResponse-endpoint with unknown authsource in state results in an exception
+     *
+     * @return void
+     */
+    public function testWithUnknownAuthSource(): void
+    {
+        $request = Request::create(
+            '/discoResponse',
+            'GET',
+            ['AuthID' => 'abc123', 'idpentityid' => 'urn:idp:entity'],
+        );
+
+        $c = new Controller\ServiceProvider($this->config, $this->session);
+        $c->setAuthState(new class () extends Auth\State {
+            public static function loadState(string $id, string $stage, bool $allowMissing = false): ?array
+            {
+                return [
+                    'saml:sp:AuthId' => 'unknown',
+                ];
+            }
+        });
+
+        $this->expectException(Exception::class);
+        $c->discoResponse($request);
+    }
+
+
+    /**
+     * Test that accessing the discoResponse-endpoint with non-SP authsource in state results in an exception
+     *
+     * @return void
+     */
+    public function testWithNonSPAuthSource(): void
+    {
+        $request = Request::create(
+            '/discoResponse',
+            'GET',
+            ['AuthID' => 'abc123', 'idpentityid' => 'urn:idp:entity'],
+        );
+
+        $c = new Controller\ServiceProvider($this->config, $this->session);
+        $c->setAuthState(new class () extends Auth\State {
+            public static function loadState(string $id, string $stage, bool $allowMissing = false): ?array
+            {
+                return [
+                    'saml:sp:AuthId' => 'admin',
+                ];
+            }
+        });
+
+        $this->expectException(Error\Exception::class);
+        $c->discoResponse($request);
+    }
+
+
+    /**
+     * Test that accessing the discoResponse-endpoint with SP authsource in state results in a RunnableResponse
+     *
+     * @return void
+     */
+    public function testWithSPAuthSource(): void
+    {
+        $request = Request::create(
+            '/discoResponse',
+            'GET',
+            ['AuthID' => 'abc123', 'idpentityid' => 'urn:idp:entity'],
+        );
+
+        $c = new Controller\ServiceProvider($this->config, $this->session);
+        $c->setAuthState(new class () extends Auth\State {
+            public static function loadState(string $id, string $stage, bool $allowMissing = false): ?array
+            {
+                return [
+                    'saml:sp:AuthId' => 'phpunit',
+                ];
+            }
+        });
+
+        $result = $c->discoResponse($request);
+        $this->assertInstanceOf(RunnableResponse::class, $result);
+    }
+
+
+    /**
+     * Test that accessing the wrongAuthnContextClassRef-endpoint without AuthID leads to a Template
+     *
+     * @return void
+     */
+    public function testWrongAuthnContextClassRef(): void
+    {
+        $c = new Controller\ServiceProvider($this->config, $this->session);
+
+        $result = $c->wrongAuthnContextClassRef();
+        $this->assertInstanceOf(Template::class, $result);
+    }
+
+
+    /**
+     * Test that accessing the ACS-endpoint with unknown SourceID results in an exception
+     *
+     * @return void
+     */
+    public function testACSWithUnknownSourceID(): void
+    {
+        $c = new Controller\ServiceProvider($this->config, $this->session);
+
+        $this->expectException(Error\Exception::class);
+        $this->expectExceptionMessage("No authentication source with id 'something' found.");
+
+        $c->assertionConsumerService('something');
+    }
+
+
+    /**
+     * Test that accessing the ACS-endpoint without being able to determine the binding results in an exception
+     *
+     * @return void
+     */
+    public function testACSWithUnkownBinding(): void
+    {
+        $_SERVER['REQUEST_METHOD'] = 'GET';
+
+        $c = new Controller\ServiceProvider($this->config, $this->session);
+
+        $this->expectException(Error\Error::class);
+        $this->expectExceptionMessage('ACSPARAMS');
+
+        $c->assertionConsumerService('phpunit');
+    }
+
+
+    /**
+     * Test that accessing the ACS-endpoint with a request instead of a response results in an exception
+     *
+     * @return void
+     */
+    public function testACSWithWrongMessage(): void
+    {
+        $q = [
+            'SAMLRequest' => 'pVJNb9swDP0rhu6O7XjeGiEJkDYoGqDbgibboZdCkahEgEx5Ir11/36y02FdD7n0JPDjPT4+cU6q9Z1c9XzCB/jRA3H23HokORYWoo8ogyJHElULJFnL3erzvZxOStnFwEEHL15BLiMUEUR2AUW2WS/EUw2NrXRp7NWshEPVzJqm+TQzVV1DddC21rUy1tq6norsO0RKyIVIRAlO1MMGiRVySpVVk1fTvKr25ZVsGvnh46PI1mkbh4pH1Im5I1kUgEeHMKE+Wh0QnnmCvlBpf0B2emwunOkKcnj0kJM7Yj7oXf2VfhOQ+hbiDuJPp+Hbw/0/8uSIdf4tO7m28zC4U7TB9KnendKAIabzO82VpjFrwKrec06dyLYv/l47NEnNZWsP5yaSd/v9Nt9+3e3Fcj5wy9GquHyPxhZYGcXqjcR58XrA/HxLX5K0zXobvNO/s9sQW8WXlQ8ZZ3I7tkqOCsmlz0iWex9+3URQDAvBsQdRLM8j/7/Y5R8=',
+            'RelayState' => 'https://profile.surfconext.nl/',
+            'SAMLEncoding' => 'urn:oasis:names:tc:SAML:2.0:bindings:URL-Encoding:DEFLATE',
+        ];
+
+        $_SERVER['REQUEST_METHOD'] = 'GET';
+        $_SERVER['QUERY_STRING'] = http_build_query($q);
+        $_GET['SAMLRequest'] = $q['SAMLRequest'];
+        $_GET['RelayState'] = $q['RelayState'];
+        $_GET['SAMLEncoding'] = $q['SAMLEncoding'];
+
+        $c = new Controller\ServiceProvider($this->config, $this->session);
+
+        $this->expectException(Error\BadRequest::class);
+        $this->expectExceptionMessage('Invalid message received at AssertionConsumerService endpoint.');
+
+        $c->assertionConsumerService('phpunit');
+    }
+
+
+    /**
+     * Test that accessing the ACS-endpoint with a response from an unknown entity leads to an exception
+     *
+     * @return void
+     */
+    public function testACSWithCorrectMessageUnknownEntity(): void
+    {
+        $q = [
+            'SAMLResponse' => 'vVdbc6rIGn2fX2G5H1MJd0RrJ1UgKmDQIIiXl1NANzcRCA2i/vppNLpNJsnMnqo5T9qL/q7r62bxEznbJO/NIMqzFMHWfpukqHcCH9tVkfYyB0WolzpbiHql1zNF/blHP5C9vMjKzMuS9o3J9xYOQrAooyxtt1T5sd2fzqxplxVIhoIMCbkuIEnehSRJ0xRJdroU1fFc6HWA4Hiw3bJhgbDtYxu7wg4QqqCaotJJSwyRFHdP0fcUb1Fsj2V7pLBut2SIyih1ypNVWJY56hFEGW6iexSBh7y8Z4WHqiweUFX4XpJV4CFNiC1Mkiwl8gyVl57gaOnlv5U9tv9HdzsSTQ4GlCB0hqQ8xD84XYHmOzxFMkOh/fSz6UbvlGTxdAkN0yBK4UOJ0zrHzFK4L5ugTlWGMC0j75QsEYEc51E6wCmdn8Stq59ntszSKSv0ftXPAGzZTlLB71lAp909s/I8iFCbeDpHeO+0J164emN3j6JzD3EddV0/1MxDVgQETZIUsdSfTS+EW+c+OhHSsHWx+nuj22HoMgwA0KV53GHKFQTHcR2PZT0SQEHgfAggECB0/8Uw/HeMANzLKMBjVhWX0wO+KpskyC6B9wAUBT/aT3+0WhdzCNTUz07e+k6apThwEh1PwXVYhhloiUmQFVEZbr9sKUU2vu/h3rv3KDb9gbnFEX7FOKX4D729y7RAzj0KHerssHE3gz4sIGa6NZ+pj+0fv0ffqUyrcFLkZ8UWvV/+Xmow3cEkyyHAZ/qtwmakf8vhp537Sfw1RzkK8KT8mw6+de+Xk9NBfdou75YRhJU/UXO3XN7ZQjh2p/mwFsXHUwK3m0/AtfHn5YfRubJ8tuhXCeETSyl2wWg+X5aA3K4SL6ZhcjciFlLVCUcCKUOKMRcSuwfiRCIPSyXNd5bvktb+TkUvuwUi2CWnOgdt42XqgZ75x2dIB0p3bB61VyllUn4Z18mEu6P8TjGtzLkrBfOIOGp70Y6D9apEu1gJkklHFgVlM1TvFra/P2SvSubm0kE6slSaB/PHazk3+f/R1DSGh2t9S47syvgIXhf95pLym1MKn3RV7d8d+31xawZirUpioGrii7Z7jg00m7GRLpKjvvk6MlWXkY2BJBlzUR+S+/5R1KRgYkviyhI3nK7PxFoOVrJtGOqgBjZQtGRFh+QNrnyBjzFu2bY2crc2xoN6eMblQd0l18sJqUfScbWgkDqaJF5q1EroTXRrXutHldQtA/8OmEWDxSdsf8ViCegGqvvGyd9oUGtTSx4YusiORGo+6Eu6Yi9nh/VikoEbXNp/jvdDXZlTtjnbcgnGV7q0OuHiXn8BI/sIZDXw6GHp9qV4vdRIXR35H/sn4v5hdxNR7kuRMZYCo78fr4p0IUzqVzCrZ7WjVJjGZ74bGENLc4GfieZzuIog7U6jTDtoNeXfeeIuj1HCmvYq3w0QVGG5VITnIN90xh3mZSNrzwIYBiwaxMMhHwfbuJgOTCbbE0FpgS4g7PpFUYndi+qNDhRybY3NB/ow4YAwmorHTNtMFuAl7tY2W+zYasdwunMQalUWDVHKWKWPayN0iWzqB3JgLCTJslTnpc7HOSZYgKpuHiez9Tw2SzeKcUNqzMGMjCV1pGDbQfDd/tdhmA+FemkNnnVxc+Yk1PvWpt4PZHF6nrvAkiib9LZ27CjGDe59gWcYn9jzzbpaL439SBYXZ1y3ZGaWeIxxUJVJ6C7qYEXbB6B+PAf1qdZBbQx1UZdEX6hlY6WNs7Ua7ryJaAyGkiHikR6IY2Gws+bkc6BoyKzwhTeF22CW5LnuayKcbqtqPQlNfUUbYbUdTte5I7rCZKjW8/HcPhy01Mw6G35TKv2x2mWRgbodnmbp0JLl1aBeaHJXCUUkvk4zmpo7QrC2GIGotzwNmXFQjJe7NIlFd/yylJeazjqbY+fAK3y926kji/dJn4y0hfLKsHFdn2+Qj5fCFTxfG8TthfLuxnkTCGblxtAr31YTrJ5UuTXEbwCn/F5WNUgE7v3T1l7ZvDgiLCDaT6TDsb7LdEm+i2Uiw3DAYSDLkKxP+RzPOMDlXZ73+TdZcQ75Ppt+lvpR47fRY+fXz/fJeNueC50CFu2vHTUNaU2ycppOC9EvYfEX5dQ9y+gZ9KK8qeX/LKIvyvSz5D88eqsS7wBR8xg1hUkQkwE/04MdXNU/qPwihSsQNW9cnH1ZRN45/LsnT7/Zlw9K8urmw/pdQOJDhdcUyjBtlDvcYoZap+VXSpjucRyu3MSyH3v4sgE03WOZHi382qqmAO4xZZwLuC5Pczzrk5zHeRTPAMahXExcx6V8yDGMA7sQeKB9mx5OusSy+hOon+BvQixpnr79bPR6XrMPwy/4p84KcG3UJ65+Rbno9zRoVo1YO1yZwpIxxaL+wceHFj6k2Y3Hz8w+CfgOuzJwRS/fT9fPq8vwP/0J',
+        ];
+
+        $_SERVER['REQUEST_METHOD'] = 'GET';
+        $_SERVER['QUERY_STRING'] = http_build_query($q);
+        $_GET['SAMLResponse'] = $q['SAMLResponse'];
+
+        $c = new Controller\ServiceProvider($this->config, $this->session);
+
+        $this->expectException(Error\MetadataNotFound::class);
+// Breaks PHP 8.1 tests
+//        $this->expectExceptionMessage("METADATANOTFOUND('%ENTITYID%' => '\'https://engine.test.surfconext.nl/authentication/idp/metadata\'')");
+
+        $c->assertionConsumerService('phpunit');
+    }
+
+
+    /**
+     * Test that accessing the SLO-endpoint with unknown SourceID results in an exception
+     *
+     * @return void
+     */
+    public function testSLOWithUnknownSourceID(): void
+    {
+        $c = new Controller\ServiceProvider($this->config, $this->session);
+
+        $this->expectException(Error\Exception::class);
+        $this->expectExceptionMessage("No authentication source with id 'something' found.");
+
+        $c->singleLogoutService('something');
+    }
+
+
+    /**
+     * Test that accessing the SLO-endpoint without being able to determine the binding results in an exception
+     *
+     * @return void
+     */
+    public function testSLOWithUnkownBinding(): void
+    {
+        $_SERVER['REQUEST_METHOD'] = 'PUT';
+        $_SERVER['QUERY_STRING'] = '';
+        unset($_GET['SAMLResponse']);
+
+        $c = new Controller\ServiceProvider($this->config, $this->session);
+
+        $this->expectException(Error\Error::class);
+        $this->expectExceptionMessage('SLOSERVICEPARAMS');
+
+        $c->singleLogoutService('phpunit');
+    }
+
+
+    /**
+     * Test that accessing the SLO-endpoint with a response from an unknown entity leads to an exception
+     *
+     * @return void
+     */
+    public function testSLOWithCorrectMessageUnknownEntity(): void
+    {
+        /** Note:  should be replaced by loading an xml-file once we can load that from saml2v5 */
+        $x = <<<XML
+<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" Version="2.0" ID="_f987e7436c1103bcf89296303f780d853d7713a8ef" IssueInstant="2020-08-15T15:53:24Z">
+  <saml:Issuer>TheIssuer</saml:Issuer>
+  <saml:EncryptedID>
+    <xenc:EncryptedData xmlns:xenc="http://www.w3.org/2001/04/xmlenc#" Type="http://www.w3.org/2001/04/xmlenc#Element">
+      <xenc:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes128-cbc"/>
+      <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
+        <xenc:EncryptedKey>
+          <xenc:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p"/>
+          <xenc:CipherData>
+            <xenc:CipherValue>QMYbOZfUgJmvzmTeSDqvA8MKzt4M2K9kb0BQtIqMgyn+OlBmRVvebZEkW/5k5E9qPtMUBCPiTatJ8aNb7Z4DuPqTRODgGzR7LEyOxe8JTPbzn/xwkVHRwzMkodDUkTDDIkJr7Tzyseg1crGZf21q8pBJfGXSaPamIC2ncjuOcs0=</xenc:CipherValue>
+          </xenc:CipherData>
+        </xenc:EncryptedKey>
+      </ds:KeyInfo>
+      <xenc:CipherData>
+        <xenc:CipherValue>U0czb6qGEvLTQaVcJDNjuAJvMAV4CcRQYvxUoBgfCP1T1xh14Kez8k0VHyEtgf6TYG7dK87SBzZccsn7MlLDLFbWH807bJ+cnXSQ9+8dXB/aohfXnmNSZUcIQiPLr5oNq1g6GYhgiNShDnYZUG3ffQ==</xenc:CipherValue>
+      </xenc:CipherData>
+    </xenc:EncryptedData>
+  </saml:EncryptedID>
+  <samlp:SessionIndex>SomeSessionIndex1</samlp:SessionIndex>
+  <samlp:SessionIndex>SomeSessionIndex2</samlp:SessionIndex>
+</samlp:LogoutRequest>
+XML;
+        $q = [
+            'SAMLRequest' => base64_encode(gzdeflate($x)),
+            'RelayState' => 'https://profile.surfconext.nl/',
+            'SAMLEncoding' => 'urn:oasis:names:tc:SAML:2.0:bindings:URL-Encoding:DEFLATE',
+        ];
+
+        $_SERVER['REQUEST_METHOD'] = 'GET';
+        $_SERVER['QUERY_STRING'] = http_build_query($q);
+        $_GET['SAMLRequest'] = $q['SAMLRequest'];
+        $_GET['RelayState'] = $q['RelayState'];
+        $_GET['SAMLEncoding'] = $q['SAMLEncoding'];
+
+        $c = new Controller\ServiceProvider($this->config, $this->session);
+
+        $this->expectException(Error\MetadataNotFound::class);
+// Breaks PHP 8.1 tests
+//        $this->expectExceptionMessage("METADATANOTFOUND('%ENTITYID%' => '\'https://engine.test.surfconext.nl/authentication/idp/metadata\'')");
+
+        $c->singleLogoutService('phpunit');
+    }
+
+
+    /**
+     * Test that accessing the metadata-endpoint with or without authentication
+     * and admin.protectmetadata set to true or false is handled properly
+     *
+     * @dataProvider provideMetadataAccess
+     * @param bool $protected
+     * @param bool $authenticated
+     * @return void
+     */
+    public function testMetadataAccess(bool $authenticated, bool $protected): void
+    {
+        $c = new Controller\ServiceProvider($this->config, $this->session);
+
+        if ($authenticated === true || $protected === false) {
+            // Bypass authentication - mock being authenticated
+            $c->setAuthUtils($this->authUtils);
+        }
+
+        $result = $c->metadata('phpunit');
+
+        if ($authenticated !== false && $protected !== true) {
+            // ($authenticated === true) or ($protected === false)
+            // Should lead to a Response
+            $this->assertInstanceOf(Response::class, $result);
+        } else {
+            $this->assertInstanceOf(RunnableResponse::class, $result);
+        }
+    }
+
+
+    /**
+     * @return array
+     */
+    public function provideMetadataAccess(): array
+    {
+        return [
+           /* [authenticated, protected] */
+           [false, false],
+           [false, true],
+           [true, false],
+           [true, true],
+        ];
+    }
+}
-- 
GitLab