diff --git a/stepup/docker-compose.yml b/stepup/docker-compose.yml
index 047710b..0d779ae 100644
--- a/stepup/docker-compose.yml
+++ b/stepup/docker-compose.yml
@@ -97,6 +97,9 @@ services:
openconextdev:
volumes:
- ${PWD}/ssp:/var/www/simplesaml/config/cert/
+ # Local sp.php with mdui:UIInfo (service name) extension support, pending upstream
+ # inclusion in OpenConext-devssp
+ - ${PWD}/ssp/sp.php:/var/www/simplesaml/public/sp.php
hostname: ssp.docker
diff --git a/stepup/ssp/sp.php b/stepup/ssp/sp.php
new file mode 100644
index 0000000..704a3cf
--- /dev/null
+++ b/stepup/ssp/sp.php
@@ -0,0 +1,711 @@
+isAuthenticated();
+
+// Build return URL. This is where ask simplesamlPHP to direct the browser to after login or logout
+// Point to this script, but without any request parameters so we won't trigger an login again (and again, and again, and ...)
+$returnURL = ($_SERVER['HTTPS'] == 'on') ? 'https://' : 'http://';
+$returnURL .= $_SERVER['HTTP_HOST'];
+$returnURL .= $_SERVER['SCRIPT_NAME'];
+$returnURL .= '?sp='.urlencode($sp);
+
+// Process login and logout actions. Neither login nor logout return
+if (isset($_REQUEST['action']) && $_REQUEST['action'] == 'login' ) {
+
+ // Save submitted form in session
+ $params_to_save=$_REQUEST;
+ unset($params_to_save['action']);
+ $session->setData('array', 'SSP_DEMO_SP_FORM_DATA', $params_to_save);
+
+ // Unset existing RequiredAuthnContextClassRef first
+ $session->deleteData('string', 'RequiredAuthnContextClassRef');
+ $bForceAuthn = false;
+ if ( (isset($_REQUEST['forceauthn'])) && ($_REQUEST['forceauthn'] == 'true') )
+ $bForceAuthn = true;
+
+ // For use by SAML2Keeper callback function
+ $session->setData('string', 'SAML2Keeper_ReturnTo', $returnURL);
+
+ $context = array(
+ 'ReturnTo' => $returnURL,
+ 'ReturnCallback' => array('sspmod_saml2keeper_SAML2Keeper','loginCallback'),
+ 'ForceAuthn' => $bForceAuthn,
+ 'saml:NameIDPolicy' => null,
+ );
+
+ // IdP
+ if ( (isset($_REQUEST['idp'])) ) {
+ $context['saml:idp'] = $_REQUEST['idp'];
+ }
+
+ // LOA
+ if ( isset($_REQUEST['loa']) && isset($_REQUEST['idp']) && isset($gIDPmap[$_REQUEST['idp']]['loa'][$_REQUEST['loa']]) ) {
+ $loa = $gIDPmap[$_REQUEST['idp']]['loa'][$_REQUEST['loa']];
+ // Store the requested LOA in the session so we can verify it later
+ $session->setData('string', 'RequiredAuthnContextClassRef', $loa);
+ $context['saml:AuthnContextClassRef'] = $loa; // Specify LOA
+ }
+
+ // Scoping IdPList
+ if ( isset($_REQUEST['scopingIDP']) && strlen($_REQUEST['scopingIDP']) > 0 ) {
+ $context['saml:IDPList'] = array($_REQUEST['scopingIDP']);
+
+ if ( isset($_REQUEST['scopingIDP2']) && strlen($_REQUEST['scopingIDP2']) > 0 ) {
+ $context['saml:IDPList'][]=$_REQUEST['scopingIDP2'];
+ }
+ }
+
+ // RequesterID
+ if ( isset($_REQUEST['requesterid']) && strlen($_REQUEST['requesterid']) > 0 ) {
+ $context['saml:RequesterID'] = array($_REQUEST['requesterid']);
+
+ if ( isset($_REQUEST['requesterid2']) && strlen($_REQUEST['requesterid2']) > 0 ) {
+ $context['saml:RequesterID'][] = $_REQUEST['requesterid2'];
+ }
+ }
+
+ // NameIDPolicy
+ if ( isset($_REQUEST['nameidpolicy']) && strlen($_REQUEST['nameidpolicy']) > 0 ) {
+ $context['saml:NameIDPolicy'] = $_REQUEST['nameidpolicy'];
+ }
+
+ // Subject NameID
+ if ( isset($_REQUEST['subject']) && strlen($_REQUEST['subject']) > 0 ) {
+ $nameId = new \SAML2\XML\saml\NameID();
+ $nameId->setValue($_REQUEST['subject']); // Use value of "NameID" attribute
+ $nameId->setFormat(\SAML2\Constants::NAMEID_UNSPECIFIED); // Unspecified NameID
+ $context['saml:NameID'] = $nameId;
+ }
+
+ // AssertionConsumerServiceURL
+ if ( isset($_REQUEST['acsurl']) && strlen($_REQUEST['acsurl']) > 0 ) {
+ $context['debugsp:AssertionConsumerServiceURL'] = $_REQUEST['acsurl'];
+ }
+
+ // Emulate an authentication request by the SURF MFA extension for ADFS. See: https://github.com/SURFnet/ADFS-MFA-SAML2.0-Extension
+ // The second factor only (SFO) endpoint of the Stepup-Gateway (see: https://github.com/OpenConext/Stepup-Gateway) will give a different
+ // SAML Response when this is enabled. This option allows testing this behaviour without having to install an ADFS server.
+ // The trigger for this behaviour in the stepup-gateway is the presence of the 'Context' and 'AuthMethod' POST variables. This also means that a HTTP-POST
+ // binding must be used.
+ // Additionally the SURF MFA extension for ADFS sets the ACS location in the SAML Request to the URL where the response must be posted.
+ // We add some dummy URL parameters to the ACS location for this purpose.
+ // Because the actual SAML response is posted in the "_SAMLResponse" parameter (instead of "SAMLResponse") the acs location of debugsp module
+ // must be used to validate the response as that will rename the "_SAMLResponse" POST variable back to "SAMLResponse" so that
+ // SimpleSAMLphp can then process it normally.
+ // For more information See: https://github.com/OpenConext/Stepup-Gateway/blob/main/docs/SFO.md#adfs-mfa-extension
+ if ( (isset($_REQUEST['emulateadfs'])) && ($_REQUEST['emulateadfs'] == 'true') )
+ {
+ // Switch binding to HTTP-POST
+ $ssobinding = $GLOBALS['gSP_SSOBinding'] = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST';
+
+ // Set AuthMethod and mock Context like the SURF MFA extension for ADFS would do
+ $context['debugsp:extraPOSTvars'] = array(
+ 'AuthMethod' => 'ADFS.SCSA',
+ 'Context' => '1C63828278F1B0AC2FE61429E099FFA7AC94917CQe1t1xgG78zLHhUxBXm0ous4yl0zfQumsKI79lrHMOIjdTdeF/i1Yx+pQ+mgnubT9mh+DfBYMs7wU1g+eXiAs2gnwKWmnMzeuxgG+m5Nky5Wd63NcEgLZ2zNTYuW70X514HMtLAw+l1H8cptQMXfXt9ageHOdY+65eq4IsNwnB0mPhRkua58R9xO3I4MfBzy90GqwgjmDeZAo5vsKgk0iZRgZ1CS4hPyIWX+ryU2tnYp5UEuDE9gGlR9cQr2uHW10LOG22ZfEy8rJie2T2A2bCQVyF47nmBnvoKYV6YyEDpozSYJpUqHmIgvaWgFu5dvDvZ0fvrVQaQ1ZUKHTT76Cg==6SO/qvyH0bmayeNGyzqAy/Oim2UAOvhxm18rTs+72Qm2fSK6Pfo1ZEDNKmLRk6IemCvkUYWMa4VmxIdATswREx/aSrp4YS3QejDBoZlCwz4LqFWJMiqTPxJfWhahP0hBNEORN8cU5vBQXXIahWqlkaHzs6IPjH4WoMe5vsSKVTetaOMbMC3ZML67BWpAnEXKWoR/gar1jH5v961ljdKJozzgwsJIAY4TNSoB+AEzRd4C3wLSTCott1DyRtMmEmS5DpaDOaxmZ/X+z16t1hb9VKgEqt1xZJ0uw451d5oeuisN9zSqbWQzyiJdkk6k11YU9q2rvg342qLJk6xeTtRc6+DLQ24vZIHC8RU2jcHveLDJvOq89BBJ0LHtnV/7PJpb4PGf1OUqWZidnRAS0/dqprEVzPEnvdzIJ8vPRGzE0dkQhgzDi+cbMsuZrDqYWaMuodvDbGrETxZ9hu0MI3l9pgjuIh8xF7TT/6qTJnGExRaGFebcjMXC99thZ3A7XeJESDNXNxgDgFQf6OwHLjLuhw==va+cZ6Y7NIyBU9vCVb+qRGSx0Yk=DNF3KVEb8ju/T+ise1j0QBS2OYsepwzgWaUtOASvUPI6NPlvyQIHxX1Py6oHcUkbWP1jaVTzwEGadaq428nPMWSeU/MDWqyz2jyrwuIUWglc64AMlcXd0BOdT1I6khKMsUGY8CSa1tRD2arcIH1TUrrk7jY3qfAGtgNbFlElPwc/2l4dkN7QXdHRcmntFp4D/9yEG9FkWzTyXLvCvGqcQeu8L1fKTwq8Upqk9iT2PKnmT/gH+IUt3votmCMV9bxYols0aQWfv2RX2HX3Gow9xKZuOn+ckjZRqBJ1Kp9wGMAB65XQPli5UQzezEHX28oUPH/PEgnu6RKDgsN55h22ag==DB540D051F8F73EA2F3B5190BFC0F349E595EB34 SAMLRequest: PHNhbWwycDpBdXRoblJlcXVlc3QgeG1sbnM6c2FtbDJwPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6cHJvdG9jb2wiIHhtbG5zOnNhbWwyPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIiBJRD0iXzkwZWFkMWNlLTM3NmMtNGE1ZC05ODAzLWQ5Y2M4MDA2M2Q0ZiIgVmVyc2lvbj0iMi4wIiBJc3N1ZUluc3RhbnQ9IjIwMTgtMDQtMjNUMTM6NDk6NTBaIiBEZXN0aW5hdGlvbj0iaHR0cHM6Ly9zYS1ndy50ZXN0Mi5zdXJmY29uZXh0Lm5sL3NlY29uZC1mYWN0b3Itb25seS9zaW5nbGUtc2lnbi1vbiIgQXNzZXJ0aW9uQ29uc3VtZXJTZXJ2aWNlVVJMPSJodHRwczovL2FkZnMtMjAxMi50ZXN0Mi5zdXJmY29uZXh0Lm5sOjQ0My9hZGZzL2xzLz9TQU1MUmVxdWVzdD1wVkpOanhNeERQMHJvOXpubzVscFlhTzJVdGtLVVdtQmFsczRjRUZ1eGtNalpaSWhkbUQ1OTZRcGlJVkRMNXdTUGZzOVB6OTVTVERhU1cwaW45MGpmbzFJWER5TjFwSEtoWldJd1NrUFpFZzVHSkVVYTNYWXZIMVFzbXJVRkR4NzdhMTRScm5OQUNJTWJMd1R4VzY3RXAlMkZiMmVtdTBYTG90SnpMUmklMkJHV1R1SHJwJTJGTCUyQlFKMXYyaFBKOTNjZFMwTUlJcVBHQ2d4VnlJSkpUcFJ4SjBqQnNjSmFtWXZ5NllyWlh1Y3RhcGJxUGJGSjFGczB6YkdBV2ZXbVhraVZkZlFEMVNtZmxseEtzdUtZaGkwZCUyRmpFbGJPNVdsdXFSYkg1YmZYZU80b2poZ09HYjBiamg4ZUhQMktUUWNaUUFaaXM0ekNMa0Jrbml6bkE4MVNQdm84V3E4djNBdFYwZldVSm1qTGE0d0RSY2ttVEtQYSUyRkluMWxYRyUyRmNsOXRwbnE1TnBONGNqJTJGdHklMkYlMkY1d0ZPdmxSVnZsZE1MNlAyMk95TkFEd3o4dWwlMkZYekdjdnJCYjFMN25iYnZiZEclMkZ5aGUlMkJ6QUMzelolMkZRVXhmRHJsVmNRQkhCaDJuNEszMTMlMkI4REF1TktjSWdvNnZWMTVOOTN1djRKJmFtcDtSZWxheVN0YXRlPWh0dHBzJTNBJTJGJTJGcGlldGVyLmFhaS5zdXJmbmV0Lm5sJTJGc2ltcGxlc2FtbHBocCUyRnNwLnBocCUzRnNwJTNEZGVmYXVsdC1zcCZhbXA7U2lnQWxnPWh0dHAlM0ElMkYlMkZ3d3cudzMub3JnJTJGMjAwMSUyRjA0JTJGeG1sZHNpZy1tb3JlJTIzcnNhLXNoYTI1NiZhbXA7U2lnbmF0dXJlPUt6TzhYV0liVTZGdUVWZVZ4RFlNZzJ1T2xoZTlBQVAwd09uWlZVM3RVMU1ibWNKUDlXa3Q1Z0R3a2RKcXhDbUlJWGVDdnBhNDVLWUdlTzNFNWppampSOHlMUFpTalJUalJRem81V2h5bzJTaXRjTkxOZzZ4WFdZY0Z6bmdIcEdKeGRyJTJGVmxjSTR0RXFUNFZSN0VwbXp3amJtd1RaRGMyOW9hdEtZRGNUUjBjTjh2M0VtMVRIR0ZOc1B0bERvRVd5c0laWjFONkRpRVAxYmE4aTE5OVhoRiUyQldXZzVzNHdqMXltMnBDendQelJkYyUyRmpreDRQcG1MTyUyQjNVY3R3amoySG5RNmNiJTJCeHBsJTJCJTJCVUFwalZ1a3ZlNWdiempMbzI4JTJCUGklMkZXQmJ0Ym0lMkZrMzE5UlZ2dWFEdVVvJTJGM3VTUU1BJTJCbHR6b0ZIOGEwRTJ0Q0pNVGg1TWRnUm10ZyUzRCUzRCI+DQogIDxzYW1sMjpJc3N1ZXI+aHR0cDovL2FkZnMtMjAxMi50ZXN0Mi5zdXJmY29uZXh0Lm5sPC9zYW1sMjpJc3N1ZXI+PFNpZ25hdHVyZSB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnIyI+PFNpZ25lZEluZm8+PENhbm9uaWNhbGl6YXRpb25NZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiIC8+PFNpZ25hdHVyZU1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZHNpZy1tb3JlI3JzYS1zaGEyNTYiIC8+PFJlZmVyZW5jZSBVUkk9IiNfOTBlYWQxY2UtMzc2Yy00YTVkLTk4MDMtZDljYzgwMDYzZDRmIj48VHJhbnNmb3Jtcz48VHJhbnNmb3JtIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnI2VudmVsb3BlZC1zaWduYXR1cmUiIC8+PFRyYW5zZm9ybSBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMTAveG1sLWV4Yy1jMTRuIyIgLz48L1RyYW5zZm9ybXM+PERpZ2VzdE1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jI3NoYTI1NiIgLz48RGlnZXN0VmFsdWU+Q2ZKV1hpTXJLU0g0b3pPVWZTL0VNOXBXbFRSc3p2QkpMN2Y3MUZrZ3ZCbz08L0RpZ2VzdFZhbHVlPjwvUmVmZXJlbmNlPjwvU2lnbmVkSW5mbz48U2lnbmF0dXJlVmFsdWU+YzFqakFhdnVjMi9TSHZQYzdJdktJVkRSZUZuZVAzbTlITkZzTzErZEx2bWhxUXl5NkhoN3ZRMlFJalVGb2FybkYxZnJpOUY1RlNxL2JsR1I4RENvNEpndUlxKzNnRXBjN1JYR2EyTWc4dE5CV2tWdUg2UnZBTGM1Qk5DSDNtODVTTHgwcklGbERWc0tzZi9IQ0lTc2taV3Z6VVJGVVRFZnZjRVljWjZZZjNXYURmK1YvbE5jYXpCeVQ0L3RmNVVFN0VIM1JYckc2dUFqbHhjekN4a09UUE1SMnIvNmxwaEF1UmIxcjY1bThYQXFZNVFnbG5SVXBOM3U3ZEt4bGN5VUVaY2xKTW5JN21ya2NyMUdwODV6allkK0N4TmFwanNEQXplVjdSSVNLdHNaY0NFYWhncStIdTZVOEtBUmZHZmdFQ1dZNGY0c2lWRGp2d0NVaTlUME13PT08L1NpZ25hdHVyZVZhbHVlPjxLZXlJbmZvPjxYNTA5RGF0YT48WDUwOUNlcnRpZmljYXRlPk1JSURFekNDQWZ1Z0F3SUJBZ0lRU044elc2Q1lsSUpPQUZ6ZFN0VFQrakFOQmdrcWhraUc5dzBCQVFzRkFEQXNNU293S0FZRFZRUUREQ0Z6YVdkdWFXNW5MbVF5TURFeUxuUmxjM1F5TG5OMWNtWmpiMjVsZUhRdWJtd3dIaGNOTVRjd09USTBNVFl6TkRFM1doY05Nakl3T1RJMU1UWXpOREUzV2pBc01Tb3dLQVlEVlFRRERDRnphV2R1YVc1bkxtUXlNREV5TG5SbGMzUXlMbk4xY21aamIyNWxlSFF1Ym13d2dnRWlNQTBHQ1NxR1NJYjNEUUVCQVFVQUE0SUJEd0F3Z2dFS0FvSUJBUUNkL1RXMUpvY05PV2w1aUZsN1JrWTQwa1A1U2NpQllCNnRJRXl3ZmxGNHRrRUR3S1Jxc1EyRXNaOTN1aWdnQi9wWFVhNlRHdlM3dnpRalJxZGhGZ0Nwb2htaGVwUzlQd3UvL0krcDY4VlpDdHNsdDFVSkd0NjJBRk9ad2FUU1FQbjRlR2RoRHI0c1g5TXIrdVVPU1plZWlEdHVFaGlNSWprZDJJYWJPeVNkOUxTK05Nc29pY1NoWEd5MERZR05yN2gyaHl1L2xUK3VMSnZsUFJ0V29aNDdpS1kwVUdpcVNJN013WlNQQjBoT2p5ZW9wbCsvWExENGhEKzNWVUFDMkttSDZaTzBBYWErc1JRZStNVFlVNHZvN29YTitkaEZoOG9VcFFrNjN4MjBtYTE1N3RSU1lqQlVwTURkclMvdk4vNWQ3c21URnQ1dFV4dlVHTGNGR0E4SkFnTUJBQUdqTVRBdk1BNEdBMVVkRHdFQi93UUVBd0lIZ0RBZEJnTlZIUTRFRmdRVUZ2ZzR3d3ByTDBlWi9ZL09zSDkyVDMrK3RsVXdEUVlKS29aSWh2Y05BUUVMQlFBRGdnRUJBRXEvQ2p5N2J0THFmWnp4dlZwUEJYZnVmYk5RSHJBeFY1QnA2a0QyY0NYUzlWQ0swdUdKdkZsemRVMDNDTE9uME4xYTJBUU5JSmZPZVg2dXlTQ1F1WXE0aDRWeUxVSVUyWE1QS1V3OGF2cWhuM0pxbGx4WUJuOE9XdENiRS9BWTdLU2lMWHk5V1BYcHRhdFpyeTV4T0x1SzYxZCtsSzJrdTlVc2xVcTY5b3BIZHhNZ3VqV0EvOUV1SkRINEVEblJ0c0lDT1oyZmxibEllQng4VU5zemExWjJ3NlIxUmtWQVN3YVZDL3JXOVpJaXhkTjdyQzUxQU1qU2YzUnBUc0cvT1Y2blNFcHJNa2hVWWRoSFdSb09XZk8rUGJmZGE1Sm85SHMyeHZENE43L2hPSjQzdC8wV0o1bng3NkNxMTNHcGlFYmlIbXZIQU1jS3R4aHVBS2M4NEk0PTwvWDUwOUNlcnRpZmljYXRlPjwvWDUwOURhdGE+PC9LZXlJbmZvPjwvU2lnbmF0dXJlPg0KICA8c2FtbDI6U3ViamVjdD4NCiAgICA8c2FtbDI6TmFtZUlEIEZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6MS4xOm5hbWVpZC1mb3JtYXQ6dW5zcGVjaWZpZWQiPnVybjpjb2xsYWI6cGVyc29uOmluc3RpdHV0aW9uLWEubmw6cGlldGVyLWExPC9zYW1sMjpOYW1lSUQ+DQogIDwvc2FtbDI6U3ViamVjdD4NCiAgPHNhbWwycDpSZXF1ZXN0ZWRBdXRobkNvbnRleHQgQ29tcGFyaXNvbj0iZXhhY3QiPg0KICAgIDxzYW1sMjpBdXRobkNvbnRleHRDbGFzc1JlZj5odHRwOi8vdGVzdDIuc3VyZmNvbmV4dC5ubC9hc3N1cmFuY2Uvc2ZvLWxldmVsMjwvc2FtbDI6QXV0aG5Db250ZXh0Q2xhc3NSZWY+DQogIDwvc2FtbDJwOlJlcXVlc3RlZEF1dGhuQ29udGV4dD4NCjwvc2FtbDJwOkF1dGhuUmVxdWVzdD4='
+ );
+ if (! isset($context['debugsp:AssertionConsumerServiceURL']) ) {
+ // Add some GET request parameters to the ACL URL. The IdP that can work with the ADFS SFO endpoint must use
+ // this exact URL so these parameters are returned. We do not actually verify this currently
+ $sspDebugSPACSURL = SimpleSAML\Module::getModuleURL('debugsp/acs/');
+ $sp=$_REQUEST['sp'];
+ $context['debugsp:AssertionConsumerServiceURL'] = $sspDebugSPACSURL.$sp.'?SAMLRequest=request_that_must_be_kept&Context=context_value_that_must_be_kept';
+ }
+ }
+
+ // SAML Extensions
+ $extensionChunks = array();
+
+ // SAML Extensions (email and SHO)
+ if ( ( isset($_REQUEST['email_extension']) && strlen($_REQUEST['email_extension']) > 0 ) ||
+ ( isset($_REQUEST['sho_extension']) && strlen($_REQUEST['sho_extension']) > 0 ) )
+ {
+ $attributes = array(); // Array of attribute name => attribute value
+ if ( isset($_REQUEST['email_extension']) && strlen($_REQUEST['email_extension']) > 0 ) {
+ $attributes['urn:mace:dir:attribute-def:mail'] = $_REQUEST['email_extension'];
+ }
+ if ( isset($_REQUEST['sho_extension']) && strlen($_REQUEST['sho_extension']) > 0 ) {
+ $attributes['urn:mace:terena.org:attribute-def:schacHomeOrganization'] = $_REQUEST['sho_extension'];
+ }
+
+ // A DOMDocument to hold the DOM structure and to serve as a factory object for DOMElements
+ // This is the method used in the Stepup-saml-bundle:
+ // https://github.com/OpenConext/Stepup-saml-bundle/blob/main/src/SAML2/Extensions/GsspUserAttributesChunk.php
+ // I'm not sure why this method is used over using new DOMElement() directly, and not using a DOMDocument at all
+ $dom = new DOMDocument('1.0', 'UTF-8');
+
+ // Create the UserAttributes extension element
+ $userAttributes = $dom->createElementNS('urn:mace:surf.nl:stepup:gssp-extensions', 'gssp:UserAttributes');
+ $userAttributes->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance');
+ $userAttributes->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:xs', 'http://www.w3.org/2001/XMLSchema');
+
+ // Add the attributes to the extension
+ foreach ($attributes as $attributeName => $attributeValue) {
+ // Create the saml:Attribute element
+ $attribute = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:assertion', 'saml:Attribute');
+ $attribute->setAttribute('NameFormat', 'urn:oasis:names:tc:SAML:2.0:attrname-format:uri');
+ $attribute->setAttribute('Name', $attributeName);
+
+ // Create the saml:AttributeValue element
+ $attributeValue = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:assertion', 'saml:AttributeValue', $attributeValue);
+ $attributeValue->setAttributeNS('http://www.w3.org/2001/XMLSchema-instance', 'xsi:type', 'xs:string');
+
+ // Append the saml:AttributeValue to saml:Attribute
+ $attribute->appendChild($attributeValue);
+
+ // Append the saml:Attribute to gssp:UserAttributes
+ $userAttributes->appendChild($attribute);
+ }
+
+ // Append the root element to the document
+ $dom->appendChild($userAttributes);
+
+ // Create a Chunk from the gssp:UserAttributes DOMElement
+ $userAttributesChunk = new \SAML2\XML\Chunk($userAttributes);
+
+ // Collect the gssp:UserAttributes chunk
+ $extensionChunks[] = $userAttributesChunk;
+ }
+
+ // mdui:UIInfo extension with a mdui:DisplayName (service name), as sent by e.g. OpenConext
+ // EngineBlock when initiating a Stepup callout. The Stepup-Gateway can read the DisplayName
+ // and forward it to the GSSP so the GSSP can show which service the user is authenticating for.
+ if ( isset($_REQUEST['mdui_displayname']) && strlen($_REQUEST['mdui_displayname']) > 0 )
+ {
+ $mduiDom = new DOMDocument('1.0', 'UTF-8');
+ $uiInfo = $mduiDom->createElementNS('urn:oasis:names:tc:SAML:metadata:ui', 'mdui:UIInfo');
+ $displayName = $mduiDom->createElementNS('urn:oasis:names:tc:SAML:metadata:ui', 'mdui:DisplayName');
+ $displayName->setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:lang', 'en');
+ $displayName->textContent = $_REQUEST['mdui_displayname'];
+ $uiInfo->appendChild($displayName);
+ $mduiDom->appendChild($uiInfo);
+ $extensionChunks[] = new \SAML2\XML\Chunk($uiInfo);
+ }
+
+ // Add the collected extensions to the SAML request
+ // The SAML2 library uses the 'saml:Extensions' element to hold the extensions which is an array of
+ // SAML2\XML\Chunk objects, one for each extension to add.
+ // The SSP will then add the extensions to the SAML request by calling setExtensions(array $extensions) : void
+ // on the SAML2\AuthnRequest object with the array of Chunk objects.
+ if ( count($extensionChunks) > 0 ) {
+ $context['saml:Extensions'] = $extensionChunks;
+ }
+
+ // login
+ $as->login( $context );
+
+ exit; // Added for clarity
+}
+
+if( isset($_REQUEST['action']) && $_REQUEST['action'] == 'logout' ) {
+ $as->logout( array (
+ 'ReturnTo' => $returnURL,
+ ) ); // Process logout
+ exit; // Added for clarity
+}
+
+if( isset($_REQUEST['action']) && $_REQUEST['action'] == 'reset' ) {
+ $session->deleteData('array', 'SSP_DEMO_SP_FORM_DATA');
+ $_REQUEST=array();
+}
+
+
+////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+// Output HTML
+
+$saved_parameters = $session->getData('array', 'SSP_DEMO_SP_FORM_DATA');
+if (!is_array($saved_parameters)) {
+ $saved_parameters = array();
+}
+if (!isset($_REQUEST['idp'])) {
+ $_REQUEST=array_merge($saved_parameters, $_REQUEST);
+}
+
+$idp=htmlentities(isset($_REQUEST['idp']) ? $_REQUEST['idp'] : "");
+$loa=htmlentities(isset($_REQUEST['loa']) ? $_REQUEST['loa'] : "");
+$nameidpolicy=htmlentities(isset($_REQUEST['nameidpolicy']) ? $_REQUEST['nameidpolicy'] : "");
+$ssobinding=htmlentities(isset($_REQUEST['ssobinding']) ? $_REQUEST['ssobinding'] : "");
+$requesterid=htmlentities(isset($_REQUEST['requesterid']) ? $_REQUEST['requesterid'] : "");
+$requesterid2=htmlentities(isset($_REQUEST['requesterid2']) ? $_REQUEST['requesterid2'] : "");
+$email_extension=htmlentities(isset($_REQUEST['email_extension']) ? $_REQUEST['email_extension'] : "");
+$sho_extension=htmlentities(isset($_REQUEST['sho_extension']) ? $_REQUEST['sho_extension'] : "");
+$mdui_displayname=htmlentities(isset($_REQUEST['mdui_displayname']) ? $_REQUEST['mdui_displayname'] : "");
+$scopingIDP=htmlentities(isset($_REQUEST['scopingIDP']) ? $_REQUEST['scopingIDP'] : "");
+$scopingIDP2=htmlentities(isset($_REQUEST['scopingIDP2']) ? $_REQUEST['scopingIDP2'] : "");
+$sp=htmlentities(isset($_REQUEST['sp']) ? $_REQUEST['sp'] : "default-sp");
+$subject=htmlentities(isset($_REQUEST['subject']) ? $_REQUEST['subject'] : "");
+$acsurl=htmlentities(isset($_REQUEST['acsurl']) ? $_REQUEST['acsurl'] : "");
+
+echo <<
';
+ }
+ else
+ {
+ echo 'Error decoding SAMLResponse (invalid base64) ';
+ }
+}
+
+echo <<
+
+html;
+
diff --git a/stepup/tests/behat/features/bootstrap/SecondFactorAuthContext.php b/stepup/tests/behat/features/bootstrap/SecondFactorAuthContext.php
index 88ac22e..2372956 100644
--- a/stepup/tests/behat/features/bootstrap/SecondFactorAuthContext.php
+++ b/stepup/tests/behat/features/bootstrap/SecondFactorAuthContext.php
@@ -137,6 +137,38 @@ public function startASfoAuthenticationWithGsspExtension(string $userIdentifier,
$this->minkContext->pressButton('Login');
}
+ /**
+ * @When I start an SFO authentication for :arg1 with service name :arg2
+ */
+ public function startASfoAuthenticationWithServiceName(string $userIdentifier, string $serviceName)
+ {
+ $this->minkContext->visit($this->spTestUrl);
+ $this->minkContext->fillField('idp', $this->activeIdp);
+ $this->minkContext->fillField('sp', $this->activeSp);
+ $this->minkContext->fillField('loa', $this->requiredLoa);
+ $this->minkContext->fillField('subject', $userIdentifier);
+ $this->minkContext->fillField('mdui_displayname', $serviceName);
+ $this->minkContext->pressButton('Login');
+ }
+
+ /**
+ * @Then I see service name :arg1 on the GSSP authentication page
+ */
+ public function iSeeServiceNameOnTheGsspAuthenticationPage(string $serviceName)
+ {
+ $this->minkContext->assertPageAddress('https://demogssp.dev.openconext.local/authentication');
+ $this->minkContext->assertPageContainsText($serviceName);
+ }
+
+ /**
+ * @Then I see service name :arg1 on the GSSP registration page
+ */
+ public function iSeeServiceNameOnTheGsspRegistrationPage(string $serviceName)
+ {
+ $this->minkContext->assertPageAddress('https://demogssp.dev.openconext.local/registration');
+ $this->minkContext->assertPageContainsText($serviceName);
+ }
+
/**
* @When I start an SFO authentication for :arg1
*/
diff --git a/stepup/tests/behat/features/gssp_service_name.feature b/stepup/tests/behat/features/gssp_service_name.feature
new file mode 100644
index 0000000..e99f3bf
--- /dev/null
+++ b/stepup/tests/behat/features/gssp_service_name.feature
@@ -0,0 +1,33 @@
+# Tagged SKIP until Stepup-Gateway PR #624 is merged and released in the test image
+# with enable_service_name_from_saml_authnrequest enabled, and the mdui-capable
+# sp.php is available. Run locally with:
+# ./start-dev-env.sh gateway: demogssp:
+# docker compose exec behat ./vendor/bin/behat --config config/behat.yml features/gssp_service_name.feature
+@SKIP
+Feature: The GSSP shows the name of the service the user is authenticating for
+ In order to know which service I am authenticating for
+ As a user
+ I want the GSSP authentication page to show the service name from the AuthnRequest
+
+ # Covers the cross-repo flow of the mdui:UIInfo service name:
+ # the SP sends an AuthnRequest with an mdui:UIInfo/mdui:DisplayName extension,
+ # the Stepup-Gateway (feature flag enable_service_name_from_saml_authnrequest)
+ # reads it and forwards it in the proxy AuthnRequest to the GSSP, where the
+ # GSSP (Stepup-gssp-example via Stepup-gssp-bundle and Stepup-saml-bundle)
+ # displays it on the authentication page.
+ Scenario: Service name from the AuthnRequest mdui:UIInfo is shown on the GSSP authentication page
+ Given a service provider configured for second-factor-only
+ And a user "jane-a-ra" identified by "urn:collab:person:institution-a.example.com:jane-a-ra" from institution "institution-a.example.com" with UUID "00000000-0000-4000-8000-000000000001"
+ And the user "urn:collab:person:institution-a.example.com:jane-a-ra" has a vetted "demo-gssp" with identifier "gssp-identifier123"
+ When I start an SFO authentication for "urn:collab:person:institution-a.example.com:jane-a-ra" with service name "Behat Test Service"
+ Then I see service name "Behat Test Service" on the GSSP authentication page
+ When I verify the "demo-gssp" second factor
+ Then I am logged on the service provider
+
+ # Reuses the identity vetted in the previous scenario, like sfo.feature does.
+ Scenario: No service name is shown when the AuthnRequest carries no mdui:UIInfo
+ Given a service provider configured for second-factor-only
+ When I start an SFO authentication for "urn:collab:person:institution-a.example.com:jane-a-ra"
+ Then I should not see "Behat Test Service"
+ When I verify the "demo-gssp" second factor
+ Then I am logged on the service provider
diff --git a/stepup/tests/playwright/.gitignore b/stepup/tests/playwright/.gitignore
new file mode 100644
index 0000000..da0275b
--- /dev/null
+++ b/stepup/tests/playwright/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+test-results/
+playwright-report/
+.playwright-mcp/
+*.png
diff --git a/stepup/tests/playwright/README.md b/stepup/tests/playwright/README.md
new file mode 100644
index 0000000..d4c05df
--- /dev/null
+++ b/stepup/tests/playwright/README.md
@@ -0,0 +1,60 @@
+# Service name e2e tests (Playwright)
+
+Cross-repo browser test for the "show service name during authentication" feature:
+Stepup-Middleware#589, Stepup-Gateway#624, Stepup-saml-bundle#137,
+Stepup-gssp-bundle#49, Stepup-gssp-example#141.
+
+## Prerequisites
+
+1. Start the devconf stepup environment (see `../../README.md`), pointing
+ `middleware` / `gateway` / `demogssp` at your local checkouts if you're
+ testing branches that aren't in the `test` images yet:
+
+ ```bash
+ cd ../..
+ ./start-dev-env.sh -d \
+ middleware:/path/to/Stepup-Middleware \
+ gateway:/path/to/Stepup-Gateway \
+ demogssp:/path/to/Stepup-gssp-example
+ ```
+
+2. `.env` must have `APP_ENV=smoketest` (routes the apps to `*_test` DBs).
+
+3. Run the Behat suite at least once against this stack so the `jane-a-ra`
+ identity (vetted `demo-gssp` second factor) exists — this test reuses
+ that fixture data rather than duplicating the bootstrap:
+
+ ```bash
+ docker compose exec behat ./vendor/bin/behat --config config/behat.yml \
+ --tags='~@wip' features/gssp_service_name.feature
+ ```
+
+4. In `Stepup-Gateway`, `enable_service_name_from_saml_authnrequest: true`
+ must be set (already the case in the devconf parameters).
+
+## Install & run
+
+```bash
+npm install
+npx playwright install chromium # first time only
+NODE_TLS_REJECT_UNAUTHORIZED=0 npx playwright test
+```
+
+`NODE_TLS_REJECT_UNAUTHORIZED=0` is needed because the devconf stack uses a
+self-signed cert and `lib/middleware.ts` pushes config over `fetch()`
+directly (not through Playwright's browser context, which is configured with
+`ignoreHTTPSErrors` separately).
+
+## What it does
+
+Each test pushes a config change to Middleware for the `second-sp` SP entity
+(`lib/middleware.ts`), then drives the SP debug page
+(`https://ssp.dev.openconext.local/simplesaml/sp.php`) through an SFO login
+to `demogssp`, asserting on what service name is shown:
+
+- No Middleware `service_name` → the SP's own `mdui:DisplayName` is shown.
+- Middleware `service_name` set → it wins, regardless of what the SP sends.
+- Neither present → no service name section renders, no error.
+
+Each test resets `second-sp`'s `service_name` back to unset in `afterEach`,
+so the shared environment is left as found.
diff --git a/stepup/tests/playwright/lib/middleware.ts b/stepup/tests/playwright/lib/middleware.ts
new file mode 100644
index 0000000..1577c9e
--- /dev/null
+++ b/stepup/tests/playwright/lib/middleware.ts
@@ -0,0 +1,54 @@
+import fs from 'node:fs';
+import path from 'node:path';
+
+const MIDDLEWARE_CONFIG_URL = 'https://middleware.dev.openconext.local/management/configuration';
+const MIDDLEWARE_CONFIG_PATH = path.resolve(__dirname, '../../../middleware/middleware-config.json');
+const MANAGEMENT_USER = 'management';
+const MANAGEMENT_PASSWORD = 'secret';
+
+type ServiceProvider = { entity_id: string; service_name?: string | null; [key: string]: unknown };
+type MiddlewareConfig = { gateway: { service_providers: ServiceProvider[]; [key: string]: unknown }; [key: string]: unknown };
+
+function loadBaseConfig(): MiddlewareConfig {
+ const raw = fs.readFileSync(MIDDLEWARE_CONFIG_PATH, 'utf-8');
+ return JSON.parse(raw) as MiddlewareConfig;
+}
+
+/**
+ * Pushes the devconf baseline middleware-config.json, optionally overriding
+ * `service_name` on one SP entity. Pass `serviceName: null` to push the
+ * baseline unmodified (no service_name key on that entity).
+ */
+export async function pushServiceName(entityId: string, serviceName: string | null): Promise {
+ const config = loadBaseConfig();
+ const sp = config.gateway.service_providers.find((s) => s.entity_id === entityId);
+ if (!sp) {
+ throw new Error(`No service provider with entity_id "${entityId}" found in ${MIDDLEWARE_CONFIG_PATH}`);
+ }
+ if (serviceName === null) {
+ delete sp.service_name;
+ } else {
+ sp.service_name = serviceName;
+ }
+
+ const auth = Buffer.from(`${MANAGEMENT_USER}:${MANAGEMENT_PASSWORD}`).toString('base64');
+ const response = await fetch(MIDDLEWARE_CONFIG_URL, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ Authorization: `Basic ${auth}`,
+ },
+ body: JSON.stringify(config),
+ // devconf uses a self-signed cert; Node's fetch needs this at the process level,
+ // see NODE_TLS_REJECT_UNAUTHORIZED=0 in the npm script / CI invocation.
+ });
+
+ if (!response.ok) {
+ throw new Error(`Middleware config push failed: HTTP ${response.status} ${await response.text()}`);
+ }
+ const body = (await response.json()) as { status?: string };
+ if (body.status !== 'OK') {
+ throw new Error(`Middleware config push did not return status OK: ${JSON.stringify(body)}`);
+ }
+}
diff --git a/stepup/tests/playwright/package-lock.json b/stepup/tests/playwright/package-lock.json
new file mode 100644
index 0000000..6b95315
--- /dev/null
+++ b/stepup/tests/playwright/package-lock.json
@@ -0,0 +1,76 @@
+{
+ "name": "stepup-service-name-e2e",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "stepup-service-name-e2e",
+ "devDependencies": {
+ "@playwright/test": "^1.48.0"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
+ "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.61.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
+ "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.61.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
+ "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/stepup/tests/playwright/package.json b/stepup/tests/playwright/package.json
new file mode 100644
index 0000000..64a687d
--- /dev/null
+++ b/stepup/tests/playwright/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "stepup-service-name-e2e",
+ "private": true,
+ "scripts": {
+ "test": "playwright test"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.48.0"
+ }
+}
diff --git a/stepup/tests/playwright/playwright.config.ts b/stepup/tests/playwright/playwright.config.ts
new file mode 100644
index 0000000..0e0f9b8
--- /dev/null
+++ b/stepup/tests/playwright/playwright.config.ts
@@ -0,0 +1,17 @@
+import { defineConfig } from '@playwright/test';
+
+// Requires the stepup devconf environment running with the `smoketest` .env profile
+// (see ../../README.md) so the app containers use *_test databases and the
+// hosts file entries for *.dev.openconext.local resolve to 127.0.0.1.
+export default defineConfig({
+ testDir: './tests',
+ timeout: 30_000,
+ fullyParallel: false,
+ reporter: 'list',
+ use: {
+ ignoreHTTPSErrors: true,
+ baseURL: 'https://ssp.dev.openconext.local',
+ screenshot: 'only-on-failure',
+ trace: 'retain-on-failure',
+ },
+});
diff --git a/stepup/tests/playwright/tests/service-name.spec.ts b/stepup/tests/playwright/tests/service-name.spec.ts
new file mode 100644
index 0000000..ceb69ea
--- /dev/null
+++ b/stepup/tests/playwright/tests/service-name.spec.ts
@@ -0,0 +1,81 @@
+import { test, expect, type Page } from '@playwright/test';
+import { pushServiceName } from '../lib/middleware';
+
+/**
+ * Cross-repo e2e coverage for the "service name during authentication" feature
+ * (Stepup-Middleware#589, Stepup-Gateway#624, Stepup-saml-bundle#137,
+ * Stepup-gssp-bundle#49, Stepup-gssp-example#141).
+ *
+ * Flow under test: SP (ssp debug SP) --AuthnRequest w/ mdui:UIInfo--> Gateway
+ * (SFO) --proxy AuthnRequest--> GSSP (demogssp) authentication page.
+ *
+ * Priority rule under test (see REFINEMENT_SERVICE_NAME.md):
+ * Middleware `service_name`, when configured for the SP, always wins over
+ * any mdui:DisplayName sent by the SP in the AuthnRequest.
+ *
+ * Prerequisites:
+ * - devconf-service-name/stepup environment running (./start-dev-env.sh -d
+ * with gateway/demogssp/middleware pointed at your local checkouts if you
+ * want to test in-progress branches).
+ * - APP_ENV=smoketest in .env (routes the apps to the *_test databases).
+ * - The Behat suite must have been run at least once against this stack
+ * (docker compose exec behat ./vendor/bin/behat --config config/behat.yml)
+ * so the "jane-a-ra" identity with a vetted demo-gssp second factor
+ * exists — that fixture setup lives in the Behat suite's @BeforeSuite
+ * hook, not duplicated here.
+ *
+ * Run: NODE_TLS_REJECT_UNAUTHORIZED=0 npx playwright test
+ */
+
+const SECOND_SP_ENTITY_ID = 'https://ssp.dev.openconext.local/simplesaml/module.php/saml/sp/metadata.php/second-sp';
+const VETTED_SUBJECT = 'urn:collab:person:institution-a.example.com:jane-a-ra';
+
+async function startSfoAuthentication(page: Page, mduiDisplayName?: string): Promise {
+ await page.goto('/simplesaml/sp.php');
+ await page.locator('#idp').selectOption('OpenConext Stepup Gateway - gateway.dev.openconext.local - SFO');
+ await page.locator('#sp').selectOption('second-sp');
+ await page.locator('#loa').selectOption('2');
+ await page.locator('#subject').fill(VETTED_SUBJECT);
+ if (mduiDisplayName) {
+ await page.locator('#mdui_displayname').fill(mduiDisplayName);
+ } else {
+ await page.locator('#mdui_displayname').fill('');
+ }
+ await page.getByRole('button', { name: 'Login' }).first().click();
+ await expect(page).toHaveURL('https://demogssp.dev.openconext.local/authentication');
+}
+
+test.describe('Service name during authentication', () => {
+ test.afterEach(async () => {
+ // Leave the shared devconf environment as we found it.
+ await pushServiceName(SECOND_SP_ENTITY_ID, null);
+ });
+
+ test('shows the AuthnRequest mdui:DisplayName when Middleware has no service_name configured', async ({ page }) => {
+ await pushServiceName(SECOND_SP_ENTITY_ID, null);
+
+ await startSfoAuthentication(page, 'Behat Test Service');
+
+ await expect(page.getByText('Behat Test Service')).toBeVisible();
+ await page.screenshot({ path: 'screenshots/01-authnrequest-mdui-shown.png', fullPage: true });
+ });
+
+ test('Middleware service_name overrides the AuthnRequest mdui:DisplayName', async ({ page }) => {
+ await pushServiceName(SECOND_SP_ENTITY_ID, 'Middleware Configured Name');
+
+ await startSfoAuthentication(page, 'Behat Test Service');
+
+ await expect(page.getByText('Middleware Configured Name')).toBeVisible();
+ await expect(page.getByText('Behat Test Service')).not.toBeVisible();
+ await page.screenshot({ path: 'screenshots/02-middleware-overrides-authnrequest.png', fullPage: true });
+ });
+
+ test('renders without error when neither Middleware service_name nor mdui:DisplayName is present', async ({ page }) => {
+ await pushServiceName(SECOND_SP_ENTITY_ID, null);
+
+ await startSfoAuthentication(page);
+
+ await expect(page.getByText('Service name')).not.toBeVisible();
+ await page.screenshot({ path: 'screenshots/03-no-service-name-no-error.png', fullPage: true });
+ });
+});