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 << + + + + + simpleSAMLphp Test SP + + +

simpleSAMLphp Test SP

+head; + +$authnInstant = ''; +$expire = ''; +if ( $bIsAuthenticated ) { + $attributes = $as->getAttributes(); + + /** @var $session SimpleSAML_Session */ + $requestedLOA = htmlentities($session->getData('string', 'RequiredAuthnContextClassRef') ?? '' ); + $IdPEntityID = htmlentities($as->getAuthData('saml:sp:IdP')); + $sessionIndex = htmlentities($as->getAuthData('saml:sp:SessionIndex') ?? ''); + $authState = $session->getAuthState($sp); + //echo "
"; print_r($authState); echo "
"; + $authenticationAuthority=$authState['saml:AuthenticatingAuthority']; // Array of AuthenticatingAuthority's + $actualLOA = htmlentities($authState['saml:sp:AuthnContext']); + $nameID = $as->getAuthData('saml:sp:NameID'); + $authnInstant = htmlentities(gmdate('r', $authState['AuthnInstant'] )); + $expire = htmlentities(gmdate('r', $authState['Expire'] )); + + echo <<You are logged in to SP:{$sp}
+ {$IdPEntityID}
+
+

+
+html; + + echo "

Session

\n"; + + echo "

"; + if (strlen($sessionIndex) > 0) { + $color=HTMLColorFingerprint($sessionIndex); + echo "{$sessionIndex}
"; + } + echo "{$authnInstant}
"; + echo "{$expire}

"; + + echo "

LOA

"; + echo "
{$actualLOA}

"; + if (strlen($requestedLOA) > 0) { + echo "
{$requestedLOA}

"; + } + + echo "

NameID

\n"; + echo "

"; + NameIDArrayToHTML($nameID); + echo "

"; + + echo <<Attributes +
+ + +html; + foreach ($attributes as $attrName => $attrVal) { + echo " \n"; + } + echo "
AttributeValue(s)
".AttributeNameToHTML($attrName)."\n"; + if (is_array($attrVal)) { + for ($i=0;$i".htmlentities($attrVal[$i]).""; + } + if ($i+1"; + } + } + } + else + echo htmlentities($attrVal); + echo "
\n"; +} else { + echo <<Your are not logged in

+html; +} + +echo <<Login (again) +
+html; + +$idpOptions=array(); +foreach ($gIDPmap as $i => $v) { + $idpOptions[$i] = $v['name']; +} +HTML_select('Identity Provider: ', 'idp', $idpOptions, $idp, "Select the IdP to authenticate to"); + +HTML_select('Request LOA: ', 'loa', + array( + "" => "None", + "1" => "1", + "1.5" => "1.5", + "2" => "2", + "3" => "3", + ), + $loa, +"Select the level of assurance to include in the AuthnContextClassRef in the AuthnRequest. The value depends on the selected IdP and the selectded LoA and is only added when the IdP supports it." +); + +echo << +   
+

+

Advanced options

+html; + // Build direct login link from submitted parameters + if (isset($_REQUEST['action']) && $_REQUEST['action'] == 'show' ) { +echo << +
+

+html; + + $params=array(); + foreach ($_REQUEST as $p => $v) { + if (strlen($v) > 0) { + if ($p=='action') { + $v='login'; + } + $params[] = urlencode($p).'='.urlencode($v); + } + } + sort($params); + $params=implode('&', $params); + $login_link=strlen($_SERVER['HTTPS']) > 0 ? 'https://' : 'http://'; + $login_link.=$_SERVER['HTTP_HOST']; + $login_link.=$_SERVER['SCRIPT_NAME']."?".$params; + echo "

"; + echo "".htmlentities($login_link)."


\n"; + echo "


"; + } + else { +echo << +
+

+html; + } + + HTML_select("Service Provider EnityID: ", "sp", + array( + 'default-sp'=>'default-sp', + 'second-sp'=>'second-sp', + 'third-sp'=>'third-sp', + 'fourth-sp'=>'fourth-sp' + ), + $sp, + "Select the EnitytID to use for this SP. This value goes into the Issuer in the AuthnRequest." + ); + HTML_select("Signing: ", "signing", + array( + 'none'=>'none', + 'rsa-sha1'=>'rsa-sha1', + 'rsa-sha256'=>'rsa-sha256', + 'rsa-sha384'=>'rsa-sha384', + 'rsa-sha512'=>'rsa-sha512', + ), + $signing, + "Select the signing algorithm the SP must use to sign the AuthnRequest, select 'none' to disable signing." + ); + + HTML_select("Signing key: ", "key", + array( + 'default'=>'default', + 'secondary'=>'secondary', + ), + $key, + "Select the key the SP must use for signing the AuthnRequest. The default key is the key that is present in the SP metadata." + ); + + HTML_select("NameIDPolicy: ", "nameidpolicy", + array( + '' => 'None', + 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified' => 'unspecified', + 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' => 'persistent', + 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient' => 'transient', + ), + $nameidpolicy, + "Select the NameID policy that the SP should request from the IdP. This value is put in the of the Format attribute in the NameIDPolicy in AuthnRequest. 'None' uses the simpleSAMLphp default behaviour based on the metadata SP and IdP metadata." + ); + + HTML_select("SSO Binding: ", "ssobinding", + array( + 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' => 'HTTP-Redirect', + 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' => 'HTTP-POST', + ), + $ssobinding, + "Specify the SAML Binding that the SP must use to send the AuthnRequest to the IdP." + ); + +$commonSPs_datalist= +<< + + +html; +$commonIDPs_datalist= +<< + + + +html; + +$sspACSURL = SimpleSAML\Module::getModuleURL('saml/sp/saml2-acs.php/'); +$sspDebugSPACSURL = SimpleSAML\Module::getModuleURL('debugsp/acs/'); + +$emulateADFSchecked = isset($_REQUEST['emulateadfs']) && $_REQUEST['emulateadfs'] == 'true' ? ' checked' : ''; +$forceAuthnChecked = isset($_REQUEST['forceauthn']) && $_REQUEST['forceauthn'] == 'true' ? ' checked' : ''; + +echo << + +
+{$commonSPs_datalist} +
+{$commonSPs_datalist} +

+

+ +
+{$commonIDPs_datalist} +
+{$commonIDPs_datalist} +

+

+ + + + +

+

+ +
+

+

+ + + + +

+

+
+

+ +

+ +
+ +
+ +
+ + +

+

+   
+

+
+html; + +$SAMLResponse = $session->getData('string', 'SAML2Keeper_SAMLResponse'); +if ($SAMLResponse) +{ + echo '

SAMLResponse

'; + $SAMLResponse = base64_decode($SAMLResponse); + if (false !== $SAMLResponse) + { + $document = new DOMDocument(); + $document->loadXML($SAMLResponse); + $xml = $document->firstChild; + + //$msg = new SAML2_Response($xml); + $response_IssueInstant = SAML2_Utils::xpQuery($xml, '/saml_protocol:Response/@IssueInstant'); + $assertion_IssueInstant = SAML2_Utils::xpQuery($xml, '/saml_protocol:Response/saml_assertion:Assertion/@IssueInstant'); + $condition_NotBefore = SAML2_Utils::xpQuery($xml, '/saml_protocol:Response/saml_assertion:Assertion/saml_assertion:Conditions/@NotBefore'); + $condition_NotOnOrAfter = SAML2_Utils::xpQuery($xml, '/saml_protocol:Response/saml_assertion:Assertion/saml_assertion:Conditions/@NotOnOrAfter'); + $audience_Restriction = SAML2_Utils::xpQuery($xml, '/saml_protocol:Response/saml_assertion:Assertion/saml_assertion:Conditions/saml_assertion:AudienceRestriction/saml:Audience'); + $assertion_AuthnInstant = SAML2_Utils::xpQuery($xml, '/saml_protocol:Response/saml_assertion:Assertion/saml_assertion:AuthnStatement/@AuthnInstant'); + $SessionNotOnOrAfter = SAML2_Utils::xpQuery($xml, '/saml_protocol:Response/saml_assertion:Assertion/saml_assertion:AuthnStatement/@SessionNotOnOrAfter'); + $SessionIndex = SAML2_Utils::xpQuery($xml, '/saml_protocol:Response/saml_assertion:Assertion/saml_assertion:AuthnStatement/@SessionIndex' ); + echo "

Response

"; + echo "IssueInstant: ".XMLTextNode2HTML_TS($response_IssueInstant)."
"; + echo "

Assertion

"; + echo "IssueInstant: ".XMLTextNode2HTML_TS($assertion_IssueInstant)."
"; + echo "Condition NotBefore: ".XMLTextNode2HTML_TS($condition_NotBefore)."
"; + echo "Condition NotOnOrAfter: ".XMLTextNode2HTML_TS($condition_NotOnOrAfter)."
"; + echo "AudienceRestriction: ".XMLTextNode2HTML($audience_Restriction)."
"; + echo "AuthnInstant: ".XMLTextNode2HTML_TS($assertion_AuthnInstant)."
"; + echo "SessionNotOnOrAfter: ".XMLTextNode2HTML_TS($SessionNotOnOrAfter)."
"; + echo "SessionIndex: ".XMLTextNode2HTML($SessionIndex)."
"; + echo '
';
+        echo htmlentities($SAMLResponse);
+        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 }); + }); +});