diff --git a/psalm-dev.xml b/psalm-dev.xml
index fc2fbd3d..071de9a8 100644
--- a/psalm-dev.xml
+++ b/psalm-dev.xml
@@ -3,6 +3,10 @@
name="SimpleSAMLphp testsuite"
useDocblockTypes="true"
errorLevel="4"
+ resolveFromConfigFile="true"
+ autoloader="vendor/autoload.php"
+ findUnusedCode="false"
+ findUnusedBaselineEntry="true"
reportMixedIssues="false"
hideExternalErrors="true"
allowStringToStandInForClass="true"
diff --git a/psalm.xml b/psalm.xml
index a7a64d4c..666c0157 100644
--- a/psalm.xml
+++ b/psalm.xml
@@ -4,10 +4,15 @@
useDocblockTypes="true"
errorLevel="2"
reportMixedIssues="false"
+ resolveFromConfigFile="true"
+ autoloader="vendor/autoload.php"
+ findUnusedCode="false"
+ findUnusedBaselineEntry="true"
+ hideExternalErrors="true"
+ allowStringToStandInForClass="true"
>
-
diff --git a/public/assets/css/casserver.css b/public/assets/css/casserver.css
new file mode 100644
index 00000000..c19fb0cd
--- /dev/null
+++ b/public/assets/css/casserver.css
@@ -0,0 +1,89 @@
+/* end baseline CSS */
+.hljs {
+ background: #F3F3F3;
+ color: #444
+}
+/* Base color: saturation 0; */
+.hljs-subst {
+ /* default */
+
+}
+/* purposely ignored */
+.hljs-formula,
+.hljs-attr,
+.hljs-property,
+.hljs-params {
+
+}
+.hljs-comment {
+ color: #697070
+}
+.hljs-tag,
+.hljs-punctuation {
+ color: #444a
+}
+.hljs-tag .hljs-name,
+.hljs-tag .hljs-attr {
+ color: #444
+}
+.hljs-keyword,
+.hljs-attribute,
+.hljs-selector-tag,
+.hljs-meta .hljs-keyword,
+.hljs-doctag,
+.hljs-name {
+ font-weight: bold
+}
+/* User color: hue: 0 */
+.hljs-type,
+.hljs-string,
+.hljs-number,
+.hljs-selector-id,
+.hljs-selector-class,
+.hljs-quote,
+.hljs-template-tag,
+.hljs-deletion {
+ color: #880000
+}
+.hljs-title,
+.hljs-section {
+ color: #880000;
+ font-weight: bold
+}
+.hljs-regexp,
+.hljs-symbol,
+.hljs-variable,
+.hljs-template-variable,
+.hljs-link,
+.hljs-selector-attr,
+.hljs-operator,
+.hljs-selector-pseudo {
+ color: #ab5656
+}
+/* Language color: hue: 90; */
+.hljs-literal {
+ color: #695
+}
+.hljs-built_in,
+.hljs-bullet,
+.hljs-code,
+.hljs-addition {
+ color: #397300
+}
+/* Meta color: hue: 200 */
+.hljs-meta {
+ color: #1f7199
+}
+.hljs-meta .hljs-string {
+ color: #38a
+}
+/* Misc effects */
+.hljs-emphasis {
+ font-style: italic
+}
+.hljs-strong {
+ font-weight: bold
+}
+.top-right-corner {
+ right: 0;
+}
diff --git a/public/cas.php b/public/cas.php
deleted file mode 100644
index 6c9f6028..00000000
--- a/public/cas.php
+++ /dev/null
@@ -1,51 +0,0 @@
- 'login',
- 'validate' => 'validate',
- 'serviceValidate' => 'serviceValidate',
- 'logout' => 'logout',
- 'proxy' => 'proxy',
- 'proxyValidate' => 'proxyValidate',
-];
-
-$function = substr($_SERVER['PATH_INFO'], 1);
-
-if (!isset($validFunctions[$function])) {
- $message = 'Not a valid function for cas.php.';
-
- \SimpleSAML\Logger::debug('casserver:' . $message);
-
- throw new \Exception($message);
-}
-
-/** @psalm-suppress UnresolvableInclude */
-include(dirname(__FILE__) . '/' . strval($validFunctions[$function]) . '.php');
diff --git a/public/loggedIn.php b/public/loggedIn.php
deleted file mode 100644
index 295f4821..00000000
--- a/public/loggedIn.php
+++ /dev/null
@@ -1,32 +0,0 @@
-send();
diff --git a/public/loggedOut.php b/public/loggedOut.php
deleted file mode 100644
index 20330c68..00000000
--- a/public/loggedOut.php
+++ /dev/null
@@ -1,36 +0,0 @@
-data['url'] = $_GET['url'];
-}
-
-$t->send();
diff --git a/public/login.php b/public/login.php
deleted file mode 100644
index 5b9798ab..00000000
--- a/public/login.php
+++ /dev/null
@@ -1,240 +0,0 @@
-checkServiceURL(sanitize($serviceUrl));
- if (isset($serviceCasConfig)) {
- // Override the cas configuration to use for this service
- $casconfig = $serviceCasConfig;
- } else {
- $message = 'Service parameter provided to CAS server is not listed as a legal service: [service] = ' .
- var_export($serviceUrl, true);
- Logger::debug('casserver:' . $message);
-
- throw new \Exception($message);
- }
-}
-
-if (array_key_exists('scope', $_GET) && is_string($_GET['scope'])) {
- $scopes = $casconfig->getOptionalValue('scopes', []);
-
- if (array_key_exists($_GET['scope'], $scopes)) {
- $idpList = $scopes[$_GET['scope']];
- } else {
- $message = 'Scope parameter provided to CAS server is not listed as legal scope: [scope] = ' .
- var_export($_GET['scope'], true);
- Logger::debug('casserver:' . $message);
-
- throw new \Exception($message);
- }
-}
-
-if (array_key_exists('language', $_GET) && is_string($_GET['language'])) {
- Language::setLanguageCookie($_GET['language']);
-}
-
-/** Initializations */
-
-// AuthSource Simple
-$as = new Simple($casconfig->getValue('authsource'));
-
-// Ticket Store
-$ticketStoreConfig = $casconfig->getOptionalValue('ticketstore', ['class' => 'casserver:FileSystemTicketStore']);
-$ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket');
-/** @var $ticketStore TicketStore */
-/** @psalm-suppress InvalidStringClass */
-$ticketStore = new $ticketStoreClass($casconfig);
-
-// Ticket Factory
-$ticketFactoryClass = Module::resolveClass('casserver:TicketFactory', 'Cas\Factories');
-/** @var $ticketFactory TicketFactory */
-/** @psalm-suppress InvalidStringClass */
-$ticketFactory = new $ticketFactoryClass($casconfig);
-
-// Processing Chain Factory
-$processingChaingFactoryClass = Module::resolveClass('casserver:ProcessingChainFactory', 'Cas\Factories');
-/** @var $processingChainFactory ProcessingChainFactory */
-/** @psalm-suppress InvalidStringClass */
-$processingChainFactory = new $processingChaingFactoryClass($casconfig);
-
-// Attribute Extractor
-$attributeExtractor = new AttributeExtractor($casconfig, $processingChainFactory);
-
-// HTTP Utils
-$httpUtils = new Utils\HTTP();
-$session = Session::getSessionFromRequest();
-
-$sessionTicket = $ticketStore->getTicket($session->getSessionId());
-$sessionRenewId = $sessionTicket ? $sessionTicket['renewId'] : null;
-$requestRenewId = $_REQUEST['renewId'] ?? null;
-// Parse the query parameters and return them in an array
-$query = parseQueryParameters($sessionTicket);
-// Construct the ReturnTo URL
-$returnUrl = $httpUtils->getSelfURLNoQuery() . '?' . http_build_query($query);
-
-// Authenticate
-if (
- !$as->isAuthenticated() || ($forceAuthn && $sessionRenewId != $requestRenewId)
-) {
- $params = [
- 'ForceAuthn' => $forceAuthn,
- 'isPassive' => $isPassive,
- 'ReturnTo' => $returnUrl,
- ];
-
- if (isset($_GET['entityId'])) {
- $params['saml:idp'] = $_GET['entityId'];
- }
-
- if (isset($idpList)) {
- if (sizeof($idpList) > 1) {
- $params['saml:IDPList'] = $idpList;
- } else {
- $params['saml:idp'] = $idpList[0];
- }
- }
-
- $as->login($params);
-}
-
-$sessionExpiry = $as->getAuthData('Expire');
-
-if (!is_array($sessionTicket) || $forceAuthn) {
- $sessionTicket = $ticketFactory->createSessionTicket($session->getSessionId(), $sessionExpiry);
- $ticketStore->addTicket($sessionTicket);
-}
-
-$parameters = [];
-
-if (array_key_exists('language', $_GET)) {
- $oldLanguagePreferred = Language::getLanguageCookie();
-
- if (isset($oldLanguagePreferred)) {
- $parameters['language'] = $oldLanguagePreferred;
- } elseif (is_string($_GET['language'])) {
- $parameters['language'] = $_GET['language'];
- }
-}
-
-// I am already logged in. Redirect to the logged in endpoint
-if (!isset($serviceUrl) && $authProcId === null) {
- // LOGGED IN
- $httpUtils->redirectTrustedURL(
- $httpUtils->addURLParameters(Module::getModuleURL('casserver/loggedIn.php'), $parameters),
- );
-}
-
-$defaultTicketName = isset($_GET['service']) ? 'ticket' : 'SAMLart';
-$ticketName = $casconfig->getOptionalValue('ticketName', $defaultTicketName);
-
-// Get the state.
-// If we come from an authproc filter, we will load the state from the stateId.
-// If not, we will get the state from the AuthSource Data
-$state = $authProcId !== null ?
- $attributeExtractor->manageState($authProcId) :
- $as->getAuthDataArray();
-
-// Attribute Handler
-$state['ReturnTo'] = $returnUrl;
-if ($authProcId !== null) {
- $state[ProcessingChain::AUTHPARAM] = $authProcId;
-}
-$mappedAttributes = $attributeExtractor->extractUserAndAttributes($state);
-
-$serviceTicket = $ticketFactory->createServiceTicket([
- 'service' => $serviceUrl,
- 'forceAuthn' => $forceAuthn,
- 'userName' => $mappedAttributes['user'],
- 'attributes' => $mappedAttributes['attributes'],
- 'proxies' => [],
- 'sessionId' => $sessionTicket['id'],
- ]);
-
-$ticketStore->addTicket($serviceTicket);
-
-$parameters[$ticketName] = $serviceTicket['id'];
-
-$validDebugModes = ['true', 'samlValidate'];
-
-// DEBUG MODE
-if (
- array_key_exists('debugMode', $_GET) &&
- in_array($_GET['debugMode'], $validDebugModes, true) &&
- $casconfig->getOptionalBoolean('debugMode', false)
-) {
- if ($_GET['debugMode'] === 'samlValidate') {
- $samlValidate = new SamlValidateResponder();
- $samlResponse = $samlValidate->convertToSaml($serviceTicket);
- $soap = $samlValidate->wrapInSoap($samlResponse);
- echo '' . htmlspecialchars(strval($soap)) . '
';
- } else {
- $method = 'serviceValidate';
- // Fake some options for validateTicket
- $_GET[$ticketName] = $serviceTicket['id'];
- // We want to capture the output from echo used in validateTicket
- ob_start();
- require_once 'utility/validateTicket.php';
- $casResponse = ob_get_contents();
- ob_end_clean();
- echo '' . htmlspecialchars($casResponse) . '
';
- }
-} elseif ($redirect) {
- // GET
- $httpUtils->redirectTrustedURL($httpUtils->addURLParameters($serviceUrl, $parameters));
-} else {
- // POST
- $httpUtils->submitPOSTData($serviceUrl, $parameters);
-}
diff --git a/public/logout.php b/public/logout.php
deleted file mode 100644
index 518c04ec..00000000
--- a/public/logout.php
+++ /dev/null
@@ -1,91 +0,0 @@
-getOptionalValue('enable_logout', false)) {
- $message = 'Logout not allowed';
-
- \SimpleSAML\Logger::debug('casserver:' . $message);
-
- throw new \Exception($message);
-}
-
-$skipLogoutPage = $casconfig->getOptionalValue('skip_logout_page', false);
-
-if ($skipLogoutPage && !array_key_exists('url', $_GET)) {
- $message = 'Required URL query parameter [url] not provided. (CAS Server)';
-
- \SimpleSAML\Logger::debug('casserver:' . $message);
-
- throw new \Exception($message);
-}
-/* Load simpleSAMLphp metadata */
-
-$as = new \SimpleSAML\Auth\Simple($casconfig->getValue('authsource'));
-
-$session = \SimpleSAML\Session::getSession();
-
-if (!is_null($session)) {
- $ticketStoreConfig = $casconfig->getOptionalValue('ticketstore', ['class' => 'casserver:FileSystemTicketStore']);
- $ticketStoreClass = \SimpleSAML\Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket');
- /** @psalm-suppress InvalidStringClass */
- $ticketStore = new $ticketStoreClass($casconfig);
-
- $ticketStore->deleteTicket($session->getSessionId());
-}
-
-$httpUtils = new \SimpleSAML\Utils\HTTP();
-
-if ($as->isAuthenticated()) {
- \SimpleSAML\Logger::debug('casserver: performing a real logout');
-
- if ($casconfig->getOptionalValue('skip_logout_page', false)) {
- $as->logout($_GET['url']);
- } else {
- $as->logout(
- $httpUtils->addURLParameters(
- \SimpleSAML\Module::getModuleURL('casserver/loggedOut.php'),
- array_key_exists('url', $_GET) ? ['url' => $_GET['url']] : [],
- ),
- );
- }
-} else {
- \SimpleSAML\Logger::debug('casserver: no session to log out of, performing redirect');
-
- if ($casconfig->getOptionalValue('skip_logout_page', false)) {
- $httpUtils->redirectTrustedURL($httpUtils->addURLParameters($_GET['url'], []));
- } else {
- $httpUtils->redirectTrustedURL(
- $httpUtils->addURLParameters(
- \SimpleSAML\Module::getModuleURL('casserver/loggedOut.php'),
- array_key_exists('url', $_GET) ? ['url' => $_GET['url']] : [],
- ),
- );
- }
-}
diff --git a/public/proxy.php b/public/proxy.php
deleted file mode 100644
index ae4182f2..00000000
--- a/public/proxy.php
+++ /dev/null
@@ -1,115 +0,0 @@
-getOptionalValue('legal_target_service_urls', []);
-
-if (
- array_key_exists('targetService', $_GET) &&
- checkServiceURL(sanitize($_GET['targetService']), $legal_target_service_urls) && array_key_exists('pgt', $_GET)
-) {
- $ticketStoreConfig = $casconfig->getOptionalValue('ticketstore', ['class' => 'casserver:FileSystemTicketStore']);
- $ticketStoreClass = \SimpleSAML\Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket');
- /** @psalm-suppress InvalidStringClass */
- $ticketStore = new $ticketStoreClass($casconfig);
-
- $ticketFactoryClass = \SimpleSAML\Module::resolveClass('casserver:TicketFactory', 'Cas\Factories');
- /** @psalm-suppress InvalidStringClass */
- $ticketFactory = new $ticketFactoryClass($casconfig);
-
- $proxyGrantingTicket = $ticketStore->getTicket($_GET['pgt']);
-
- if (!is_null($proxyGrantingTicket) && $ticketFactory->isProxyGrantingTicket($proxyGrantingTicket)) {
- $sessionTicket = $ticketStore->getTicket($proxyGrantingTicket['sessionId']);
-
- if (
- !is_null($sessionTicket) &&
- $ticketFactory->isSessionTicket($sessionTicket) &&
- !$ticketFactory->isExpired($sessionTicket)
- ) {
- $proxyTicket = $ticketFactory->createProxyTicket(
- ['service' => $_GET['targetService'],
- 'forceAuthn' => $proxyGrantingTicket['forceAuthn'],
- 'attributes' => $proxyGrantingTicket['attributes'],
- 'proxies' => $proxyGrantingTicket['proxies'],
- 'sessionId' => $proxyGrantingTicket['sessionId'],
- ],
- );
-
- $ticketStore->addTicket($proxyTicket);
-
- echo $protocol->getProxySuccessResponse($proxyTicket['id']);
- } else {
- $message = 'Ticket ' . var_export($_GET['pgt'], true) . ' has expired';
-
- \SimpleSAML\Logger::debug('casserver:' . $message);
-
- echo $protocol->getProxyFailureResponse('BAD_PGT', $message);
- }
- } elseif (!$ticketFactory->isProxyGrantingTicket($proxyGrantingTicket)) {
- $message = 'Not a valid proxy granting ticket id: ' . var_export($_GET['pgt'], true);
-
- \SimpleSAML\Logger::debug('casserver:' . $message);
-
- echo $protocol->getProxyFailureResponse('BAD_PGT', $message);
- } else {
- $message = 'Ticket ' . var_export($_GET['pgt'], true) . ' not recognized';
-
- \SimpleSAML\Logger::debug('casserver:' . $message);
-
- echo $protocol->getProxyFailureResponse('BAD_PGT', $message);
- }
-} elseif (!array_key_exists('targetService', $_GET)) {
- $message = 'Missing target service parameter [targetService]';
-
- \SimpleSAML\Logger::debug('casserver:' . $message);
-
- echo $protocol->getProxyFailureResponse(C::ERR_INVALID_REQUEST, $message);
-} elseif (!checkServiceURL(sanitize($_GET['targetService']), $legal_target_service_urls)) {
- $message = 'Target service parameter not listed as a legal service: [targetService] = ' .
- var_export($_GET['targetService'], true);
-
- \SimpleSAML\Logger::debug('casserver:' . $message);
-
- echo $protocol->getProxyFailureResponse(C::ERR_INVALID_REQUEST, $message);
-} else {
- $message = 'Missing proxy granting ticket parameter: [pgt]';
-
- \SimpleSAML\Logger::debug('casserver:' . $message);
-
- echo $protocol->getProxyFailureResponse(C::ERR_INVALID_REQUEST, $message);
-}
diff --git a/public/proxyValidate.php b/public/proxyValidate.php
deleted file mode 100644
index 01ab183d..00000000
--- a/public/proxyValidate.php
+++ /dev/null
@@ -1,33 +0,0 @@
-(.*)validateAndDeleteTicket($ticketId, $target);
-if (!is_array($ticket)) {
- throw new \Exception('Error loading ticket');
-}
-$samlValidator = new SamlValidateResponder();
-$response = $samlValidator->convertToSaml($ticket);
-$soap = $samlValidator->wrapInSoap($response);
-
-echo strval($soap);
diff --git a/public/serviceValidate.php b/public/serviceValidate.php
deleted file mode 100644
index 8697a196..00000000
--- a/public/serviceValidate.php
+++ /dev/null
@@ -1,33 +0,0 @@
- $legal_service_urls]);
- $serviceValidator = new ServiceValidator($config);
- return $serviceValidator->checkServiceURL($service) !== null;
-}
-
-
-/**
- * @param string $parameter
- * @return string
- */
-function sanitize(string $parameter): string
-{
- return TicketValidator::sanitize($parameter);
-}
-
-
-/**
- * Parse the query Parameters from $_GET global and return them in an array.
- *
- * @param array|null $sessionTicket
- *
- * @return array
- */
-function parseQueryParameters(?array $sessionTicket): array
-{
- $forceAuthn = isset($_GET['renew']) && $_GET['renew'];
- $sessionRenewId = $sessionTicket ? $sessionTicket['renewId'] : null;
-
- $query = [];
-
- if ($sessionRenewId && $forceAuthn) {
- $query['renewId'] = $sessionRenewId;
- }
-
- if (isset($_REQUEST['service'])) {
- $query['service'] = $_REQUEST['service'];
- }
-
- if (isset($_REQUEST['TARGET'])) {
- $query['TARGET'] = $_REQUEST['TARGET'];
- }
-
- if (isset($_REQUEST['method'])) {
- $query['method'] = $_REQUEST['method'];
- }
-
- if (isset($_REQUEST['renew'])) {
- $query['renew'] = $_REQUEST['renew'];
- }
-
- if (isset($_REQUEST['gateway'])) {
- $query['gateway'] = $_REQUEST['gateway'];
- }
-
- if (array_key_exists('language', $_GET)) {
- $query['language'] = is_string($_GET['language']) ? $_GET['language'] : null;
- }
-
- if (isset($_REQUEST['debugMode'])) {
- $query['debugMode'] = $_REQUEST['debugMode'];
- }
-
- return $query;
-}
diff --git a/public/utility/validateTicket.php b/public/utility/validateTicket.php
deleted file mode 100644
index 0d639d26..00000000
--- a/public/utility/validateTicket.php
+++ /dev/null
@@ -1,202 +0,0 @@
-getOptionalValue(
- 'ticketstore',
- ['class' => 'casserver:FileSystemTicketStore'],
- );
- $ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket');
- /** @var TicketStore $ticketStore */
- /** @psalm-suppress InvalidStringClass */
- $ticketStore = new $ticketStoreClass($casconfig);
-
- $ticketFactoryClass = Module::resolveClass('casserver:TicketFactory', 'Cas\Factories');
- /** @var TicketFactory $ticketFactory */
- /** @psalm-suppress InvalidStringClass */
- $ticketFactory = new $ticketFactoryClass($casconfig);
-
- $serviceTicket = $ticketStore->getTicket($_GET['ticket']);
-
- /**
- * @psalm-suppress UndefinedGlobalVariable
- * @psalm-suppress TypeDoesNotContainType
- */
- if (
- !is_null($serviceTicket) && ($ticketFactory->isServiceTicket($serviceTicket) ||
- ($ticketFactory->isProxyTicket($serviceTicket) && $method === 'proxyValidate'))
- ) {
- $ticketStore->deleteTicket($_GET['ticket']);
-
- $attributes = $serviceTicket['attributes'];
-
- if (
- !$ticketFactory->isExpired($serviceTicket) &&
- sanitize($serviceTicket['service']) == sanitize($serviceUrl) &&
- (!$forceAuthn || $serviceTicket['forceAuthn'])
- ) {
- $protocol->setAttributes($attributes);
-
- if (isset($_GET['pgtUrl'])) {
- $sessionTicket = $ticketStore->getTicket($serviceTicket['sessionId']);
-
- $pgtUrl = $_GET['pgtUrl'];
-
- if (
- !is_null($sessionTicket) && $ticketFactory->isSessionTicket($sessionTicket) &&
- !$ticketFactory->isExpired($sessionTicket)
- ) {
- $proxyGrantingTicket = $ticketFactory->createProxyGrantingTicket([
- 'userName' => $serviceTicket['userName'],
- 'attributes' => $attributes,
- 'forceAuthn' => false,
- 'proxies' => array_merge([$serviceUrl], $serviceTicket['proxies']),
- 'sessionId' => $serviceTicket['sessionId'],
- ]);
- $httpUtils = new Utils\HTTP();
- try {
- $httpUtils->fetch($pgtUrl . '?pgtIou=' . $proxyGrantingTicket['iou'] .
- '&pgtId=' . $proxyGrantingTicket['id']);
-
- $protocol->setProxyGrantingTicketIOU($proxyGrantingTicket['iou']);
-
- $ticketStore->addTicket($proxyGrantingTicket);
- } catch (Exception $e) {
- // Fall through
- }
- }
- }
-
- echo $protocol->getValidateSuccessResponse($serviceTicket['userName']);
- } else {
- if ($ticketFactory->isExpired($serviceTicket)) {
- $message = 'Ticket ' . var_export($_GET['ticket'], true) . ' has expired';
-
- Logger::debug('casserver:' . $message);
-
- echo $protocol->getValidateFailureResponse(C::ERR_INVALID_TICKET, $message);
- } else {
- if (sanitize($serviceTicket['service']) != sanitize($serviceUrl)) {
- $message = 'Mismatching service parameters: expected ' .
- var_export($serviceTicket['service'], true) .
- ' but was: ' . var_export($serviceUrl, true);
-
- Logger::debug('casserver:' . $message);
-
- echo $protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message);
- } else {
- if ($serviceTicket['forceAuthn'] != $forceAuthn) {
- $message = 'Ticket was issue from single sign on session';
-
- Logger::debug('casserver:' . $message);
-
- echo $protocol->getValidateFailureResponse(C::ERR_INVALID_TICKET, $message);
- } else {
- Logger::error('casserver:' . $method . ': internal server error.');
-
- echo $protocol->getValidateFailureResponse(C::ERR_INTERNAL_ERROR, 'Unknown internal error');
- }
- }
- }
- }
- } else {
- if (is_null($serviceTicket)) {
- $message = 'Ticket ' . var_export($_GET['ticket'], true) . ' not recognized';
-
- Logger::debug('casserver:' . $message);
-
- echo $protocol->getValidateFailureResponse(C::ERR_INVALID_TICKET, $message);
- } else {
- /**
- * @psalm-suppress UndefinedGlobalVariable
- * @psalm-suppress TypeDoesNotContainType
- * @psalm-suppress RedundantCondition
- */
- if ($ticketFactory->isProxyTicket($serviceTicket) && ($method === 'serviceValidate')) {
- $message = 'Ticket ' . var_export($_GET['ticket'], true) .
- ' is a proxy ticket. Use proxyValidate instead.';
-
- Logger::debug('casserver:' . $message);
-
- echo $protocol->getValidateFailureResponse(C::ERR_INVALID_TICKET, $message);
- } else {
- $message = 'Ticket ' . var_export($_GET['ticket'], true) . ' is not a service ticket';
-
- Logger::debug('casserver:' . $message);
-
- echo $protocol->getValidateFailureResponse(C::ERR_INVALID_TICKET, $message);
- }
- }
- }
- } catch (Exception $e) {
- Logger::error(
- 'casserver:serviceValidate: internal server error. ' . var_export($e->getMessage(), true),
- );
-
- echo $protocol->getValidateFailureResponse(C::ERR_INTERNAL_ERROR, $e->getMessage());
- }
-} else {
- if (!array_key_exists('service', $_GET)) {
- $message = 'Missing service parameter: [service]';
-
- Logger::debug('casserver:' . $message);
-
- echo $protocol->getValidateFailureResponse(C::ERR_INVALID_REQUEST, $message);
- } else {
- $message = 'Missing ticket parameter: [ticket]';
-
- Logger::debug('casserver:' . $message);
-
- echo $protocol->getValidateFailureResponse(C::ERR_INVALID_REQUEST, $message);
- }
-}
diff --git a/public/validate.php b/public/validate.php
deleted file mode 100644
index cb073253..00000000
--- a/public/validate.php
+++ /dev/null
@@ -1,123 +0,0 @@
-getOptionalValue(
- 'ticketstore',
- ['class' => 'casserver:FileSystemTicketStore'],
- );
- $ticketStoreClass = \SimpleSAML\Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket');
- /** @psalm-suppress InvalidStringClass */
- $ticketStore = new $ticketStoreClass($casconfig);
-
- $ticketFactoryClass = \SimpleSAML\Module::resolveClass('casserver:TicketFactory', 'Cas\Factories');
- /** @psalm-suppress InvalidStringClass */
- $ticketFactory = new $ticketFactoryClass($casconfig);
-
- $serviceTicket = $ticketStore->getTicket($_GET['ticket']);
-
- if (!is_null($serviceTicket) && $ticketFactory->isServiceTicket($serviceTicket)) {
- $ticketStore->deleteTicket($_GET['ticket']);
-
- $usernameField = $casconfig->getOptionalValue('attrname', 'eduPersonPrincipalName');
-
- if (
- !$ticketFactory->isExpired($serviceTicket) &&
- sanitize($serviceTicket['service']) == sanitize($_GET['service']) &&
- (!$forceAuthn || $serviceTicket['forceAuthn']) &&
- array_key_exists($usernameField, $serviceTicket['attributes'])
- ) {
- echo $protocol->getValidateSuccessResponse($serviceTicket['attributes'][$usernameField][0]);
- } else {
- if (!array_key_exists($usernameField, $serviceTicket['attributes'])) {
- \SimpleSAML\Logger::error(sprintf(
- 'casserver:validate: internal server error. Missing user name attribute: %s',
- var_export($usernameField, true),
- ));
-
- echo $protocol->getValidateFailureResponse();
- } else {
- if ($ticketFactory->isExpired($serviceTicket)) {
- $message = 'Ticket has ' . var_export($_GET['ticket'], true) . ' expired';
- } else {
- if (sanitize($serviceTicket['service']) == sanitize($_GET['service'])) {
- $message = 'Mismatching service parameters: expected ' .
- var_export($serviceTicket['service'], true) .
- ' but was: ' . var_export($_GET['service'], true);
- } else {
- $message = 'Ticket was issue from single sign on session';
- }
- }
- \SimpleSAML\Logger::debug('casserver:' . $message);
-
- echo $protocol->getValidateFailureResponse();
- }
- }
- } else {
- if (is_null($serviceTicket)) {
- $message = 'ticket: ' . var_export($_GET['ticket'], true) . ' not recognized';
- } else {
- $message = 'ticket: ' . var_export($_GET['ticket'], true) . ' is not a service ticket';
- }
-
- \SimpleSAML\Logger::debug('casserver:' . $message);
-
- echo $protocol->getValidateFailureResponse();
- }
- } catch (\Exception $e) {
- \SimpleSAML\Logger::error('casserver:validate: internal server error. ' . var_export($e->getMessage(), true));
-
- echo $protocol->getValidateFailureResponse();
- }
-} else {
- if (!array_key_exists('service', $_GET)) {
- $message = 'Missing service parameter: [service]';
- } else {
- $message = 'Missing ticket parameter: [ticket]';
- }
-
- SimpleSAML\Logger::debug('casserver:' . $message);
-
- echo $protocol->getValidateFailureResponse();
-}
diff --git a/routing/routes/routes.php b/routing/routes/routes.php
new file mode 100644
index 00000000..999a4a60
--- /dev/null
+++ b/routing/routes/routes.php
@@ -0,0 +1,71 @@
+add(RoutesEnum::Validate->name, RoutesEnum::Validate->value)
+ ->controller([Cas10Controller::class, 'validate']);
+ $routes->add(RoutesEnum::ServiceValidate->name, RoutesEnum::ServiceValidate->value)
+ ->controller([Cas20Controller::class, 'serviceValidate'])
+ ->methods(['GET']);
+ $routes->add(RoutesEnum::ProxyValidate->name, RoutesEnum::ProxyValidate->value)
+ ->controller([Cas20Controller::class, 'proxyValidate'])
+ ->methods(['GET']);
+ $routes->add(RoutesEnum::Proxy->name, RoutesEnum::Proxy->value)
+ ->controller([Cas20Controller::class, 'proxy'])
+ ->methods(['GET']);
+ $routes->add(RoutesEnum::SamlValidate->name, RoutesEnum::SamlValidate->value)
+ ->controller([Cas30Controller::class, 'samlValidate'])
+ ->methods(['POST']);
+ $routes->add(RoutesEnum::Logout->name, RoutesEnum::Logout->value)
+ ->controller([LogoutController::class, 'logout']);
+ $routes->add(RoutesEnum::LoggedOut->name, RoutesEnum::LoggedOut->value)
+ ->controller([LoggedOutController::class, 'main']);
+ $routes->add(RoutesEnum::Login->name, RoutesEnum::Login->value)
+ ->controller([LoginController::class, 'login']);
+ $routes->add(RoutesEnum::LoggedIn->name, RoutesEnum::LoggedIn->value)
+ ->controller([LoggedInController::class, 'main']);
+
+ // Legacy Routes
+ $routes->add(LegacyRoutesEnum::LegacyValidate->name, LegacyRoutesEnum::LegacyValidate->value)
+ ->controller([Cas10Controller::class, 'validate']);
+ $routes->add(LegacyRoutesEnum::LegacyServiceValidate->name, LegacyRoutesEnum::LegacyServiceValidate->value)
+ ->controller([Cas20Controller::class, 'serviceValidate'])
+ ->methods(['GET']);
+ $routes->add(LegacyRoutesEnum::LegacyProxyValidate->name, LegacyRoutesEnum::LegacyProxyValidate->value)
+ ->controller([Cas20Controller::class, 'proxyValidate'])
+ ->methods(['GET']);
+ $routes->add(LegacyRoutesEnum::LegacyProxy->name, LegacyRoutesEnum::LegacyProxy->value)
+ ->controller([Cas20Controller::class, 'proxy'])
+ ->methods(['GET']);
+ $routes->add(LegacyRoutesEnum::LegacySamlValidate->name, LegacyRoutesEnum::LegacySamlValidate->value)
+ ->controller([Cas30Controller::class, 'samlValidate'])
+ ->methods(['POST']);
+ $routes->add(LegacyRoutesEnum::LegacyLogout->name, LegacyRoutesEnum::LegacyLogout->value)
+ ->controller([LogoutController::class, 'logout']);
+ $routes->add(LegacyRoutesEnum::LegacyLoggedOut->name, LegacyRoutesEnum::LegacyLoggedOut->value)
+ ->controller([LoggedOutController::class, 'main']);
+ $routes->add(LegacyRoutesEnum::LegacyLogin->name, LegacyRoutesEnum::LegacyLogin->value)
+ ->controller([LoginController::class, 'login']);
+ $routes->add(LegacyRoutesEnum::LegacyLoggedIn->name, LegacyRoutesEnum::LegacyLoggedIn->value)
+ ->controller([LoggedInController::class, 'main']);
+};
diff --git a/routing/services/services.yml b/routing/services/services.yml
new file mode 100644
index 00000000..7348ffa9
--- /dev/null
+++ b/routing/services/services.yml
@@ -0,0 +1,38 @@
+---
+
+services:
+ # default configuration for services in *this* file
+ _defaults:
+ public: false
+ autowire: true # Automatically injects dependencies in your services.
+ autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
+
+ SimpleSAML\Module\casserver\Controller\:
+ resource: '../../src/Controller/*'
+ exclude:
+ - '../../src/Controller/Traits/*'
+ public: true
+ tags: [ 'controller.service_arguments' ]
+
+
+ # Explicit service definitions for CasServer Controllers
+ SimpleSAML\Module\casserver\Controller\Cas10Controller:
+ public: true
+
+ SimpleSAML\Module\casserver\Controller\Cas20Controller:
+ public: true
+
+ SimpleSAML\Module\casserver\Controller\Cas30Controller:
+ public: true
+
+ SimpleSAML\Module\casserver\Controller\LogoutController:
+ public: true
+
+ SimpleSAML\Module\casserver\Controller\LoggedOutController:
+ public: true
+
+ SimpleSAML\Module\casserver\Controller\LoggedInController:
+ public: true
+
+ SimpleSAML\Module\casserver\Controller\LoginController:
+ public: true
\ No newline at end of file
diff --git a/src/Cas/AttributeExtractor.php b/src/Cas/AttributeExtractor.php
index 275f38d8..291574e2 100644
--- a/src/Cas/AttributeExtractor.php
+++ b/src/Cas/AttributeExtractor.php
@@ -55,7 +55,7 @@ public function __construct(
* @param array|null $state
*
* @return array
- * @throws Exception
+ * @throws \Exception
*/
public function extractUserAndAttributes(?array $state): array
{
@@ -76,12 +76,11 @@ public function extractUserAndAttributes(?array $state): array
throw new \Exception("No cas user defined for attribute $casUsernameAttribute");
}
+ $casAttributes = [];
if ($this->casconfig->getOptionalValue('attributes', true)) {
$attributesToTransfer = $this->casconfig->getOptionalValue('attributes_to_transfer', []);
if (sizeof($attributesToTransfer) > 0) {
- $casAttributes = [];
-
foreach ($attributesToTransfer as $key) {
if (\array_key_exists($key, $attributes)) {
$casAttributes[$key] = $attributes[$key];
@@ -90,8 +89,6 @@ public function extractUserAndAttributes(?array $state): array
} else {
$casAttributes = $attributes;
}
- } else {
- $casAttributes = [];
}
return [
diff --git a/src/Cas/Protocol/Cas20.php b/src/Cas/Protocol/Cas20.php
index 4c63d1c8..23077392 100644
--- a/src/Cas/Protocol/Cas20.php
+++ b/src/Cas/Protocol/Cas20.php
@@ -185,7 +185,7 @@ public function getValidateFailureResponse(string $errorCode, string $explanatio
public function getProxySuccessResponse(string $proxyTicketId): ServiceResponse
{
$proxyTicket = new ProxyTicket($proxyTicketId);
- $proxySucces = new ProxySuccess($proxyTicket);
+ $proxySuccess = new ProxySuccess($proxyTicket);
$serviceResponse = new ServiceResponse($proxySuccess);
return $serviceResponse;
diff --git a/src/Cas/ServiceValidator.php b/src/Cas/ServiceValidator.php
index f6dd8897..d3cb6732 100644
--- a/src/Cas/ServiceValidator.php
+++ b/src/Cas/ServiceValidator.php
@@ -6,6 +6,7 @@
use SimpleSAML\Configuration;
use SimpleSAML\Logger;
+use SimpleSAML\Module\casserver\Codebooks\OverrideConfigPropertiesEnum;
/**
* Validates if a CAS service can use server
@@ -29,53 +30,81 @@ public function __construct(Configuration $mainConfig)
/**
* Check that the $service is allowed, and if so return the configuration to use.
- * @param string $service The service url. Assume to already be url decoded
+ *
+ * @param string $service The service url. Assume to already be url decoded
+ *
* @return Configuration|null Return the configuration to use for this service, or null if service is not allowed
+ * @throws \ErrorException
*/
public function checkServiceURL(string $service): ?Configuration
{
$isValidService = false;
$legalUrl = 'undefined';
$configOverride = null;
- foreach ($this->mainConfig->getOptionalArray('legal_service_urls', []) as $index => $value) {
+ $legalServiceUrlsConfig = $this->mainConfig->getOptionalArray('legal_service_urls', []);
+
+ foreach ($legalServiceUrlsConfig as $index => $value) {
// Support two styles: 0 => 'https://example' and 'https://example' => [ extra config ]
- if (is_int($index)) {
- $legalUrl = $value;
- $configOverride = null;
- } else {
- $legalUrl = $index;
- $configOverride = $value;
- }
+ $legalUrl = \is_int($index) ? $value : $index;
if (empty($legalUrl)) {
Logger::warning("Ignoring empty CAS legal service url '$legalUrl'.");
continue;
}
- if (!ctype_alnum($legalUrl[0])) {
- // Probably a regex. Suppress errors incase the format is invalid
- $result = @preg_match($legalUrl, $service);
- if ($result === 1) {
- $isValidService = true;
- break;
- } elseif ($result === false) {
- Logger::warning("Invalid CAS legal service url '$legalUrl'. Error " . preg_last_error());
+
+ $configOverride = \is_int($index) ? null : $value;
+
+ // URL String
+ if (str_starts_with($service, $legalUrl)) {
+ $isValidService = true;
+ break;
+ }
+
+ // Regex
+ // Since "If the regex pattern passed does not compile to a valid regex, an E_WARNING is emitted. "
+ // we will throw an exception if the warning is emitted and use try-catch to handle it
+ set_error_handler(static function ($severity, $message, $file, $line) {
+ throw new \ErrorException($message, $severity, $severity, $file, $line);
+ }, E_WARNING);
+
+ try {
+ $result = preg_match($legalUrl, $service);
+ if ($result !== 1) {
+ throw new \RuntimeException('Service URL does not match legal service URL.');
}
- } elseif (strpos($service, $legalUrl) === 0) {
$isValidService = true;
break;
+ } catch (\RuntimeException $e) {
+ // do nothing
+ Logger::warning($e->getMessage());
+ } catch (\Exception $e) {
+ // do nothing
+ Logger::warning("Invalid CAS legal service url '$legalUrl'. Error " . preg_last_error());
+ } finally {
+ restore_error_handler();
}
}
- if ($isValidService) {
- $serviceConfig = $this->mainConfig->toArray();
- // Return contextual information about which url rule triggered the validation
- $serviceConfig['casService'] = [
- 'matchingUrl' => $legalUrl,
- 'serviceUrl' => $service,
- ];
- if ($configOverride) {
- $serviceConfig = array_merge($serviceConfig, $configOverride);
- }
- return Configuration::loadFromArray($serviceConfig);
+
+ if (!$isValidService) {
+ return null;
+ }
+
+ $serviceConfig = $this->mainConfig->toArray();
+ // Return contextual information about which url rule triggered the validation
+ $serviceConfig['casService'] = [
+ 'matchingUrl' => $legalUrl,
+ 'serviceUrl' => $service,
+ ];
+ if ($configOverride !== null) {
+ // We need to remove all the unsupported configuration keys
+ $supportedProperties = array_column(OverrideConfigPropertiesEnum::cases(), 'value');
+ $configOverride = array_filter(
+ $configOverride,
+ static fn($property) => \in_array($property, $supportedProperties, true),
+ ARRAY_FILTER_USE_KEY,
+ );
+ // Merge the configurations
+ $serviceConfig = array_merge($serviceConfig, $configOverride);
}
- return null;
+ return Configuration::loadFromArray($serviceConfig);
}
}
diff --git a/src/Cas/Ticket/SQLTicketStore.php b/src/Cas/Ticket/SQLTicketStore.php
index 5ab0d844..0603ed8c 100644
--- a/src/Cas/Ticket/SQLTicketStore.php
+++ b/src/Cas/Ticket/SQLTicketStore.php
@@ -321,9 +321,11 @@ private function get(string $key): ?array
/**
- * @param string $key
- * @param array $value
- * @param int|null $expire
+ * @param string $key
+ * @param array $value
+ * @param int|null $expire
+ *
+ * @throws Exception
*/
private function set(string $key, array $value, int $expire = null): void
{
diff --git a/src/Codebooks/LegacyRoutesEnum.php b/src/Codebooks/LegacyRoutesEnum.php
new file mode 100644
index 00000000..36f6bf31
--- /dev/null
+++ b/src/Codebooks/LegacyRoutesEnum.php
@@ -0,0 +1,19 @@
+casConfig = ($casConfig === null || $casConfig === $sspConfig)
+ ? Configuration::getConfig('module_casserver.php') : $casConfig;
+ $this->cas10Protocol = new Cas10($this->casConfig);
+ /* Instantiate ticket factory */
+ $this->ticketFactory = new TicketFactory($this->casConfig);
+ /* Instantiate ticket store */
+ $ticketStoreConfig = $this->casConfig->getOptionalValue(
+ 'ticketstore',
+ ['class' => 'casserver:FileSystemTicketStore'],
+ );
+ $ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket');
+ $this->ticketStore = $ticketStore ?? new $ticketStoreClass($this->casConfig);
+ }
+
+ /**
+ * @param Request $request
+ * @param bool $renew [OPTIONAL] - if this parameter is set, ticket validation will only succeed
+ * if the service ticket was issued from the presentation of the user’s primary credentials.
+ * It will fail if the ticket was issued from a single sign-on session.
+ * @param string|null $ticket [REQUIRED] - the service ticket issued by /login.
+ * @param string|null $service [REQUIRED] - the identifier of the service for which the ticket was issued
+ *
+ * @return Response
+ */
+ public function validate(
+ Request $request,
+ #[MapQueryParameter] ?string $ticket = null,
+ #[MapQueryParameter] bool $renew = false,
+ #[MapQueryParameter] ?string $service = null,
+ ): Response {
+ $forceAuthn = $renew;
+ // Check if any of the required query parameters are missing
+ // Even though we can delegate the check to Symfony's `MapQueryParameter` we cannot return
+ // the failure response needed. As a result, we allow a default value, and we handle the missing
+ // values afterward.
+ if ($service === null || $ticket === null) {
+ $messagePostfix = $service === null ? 'service' : 'ticket';
+ Logger::debug("casserver: Missing service parameter: [{$messagePostfix}]");
+ return new Response(
+ $this->cas10Protocol->getValidateFailureResponse(),
+ Response::HTTP_BAD_REQUEST,
+ );
+ }
+
+ try {
+ // Get the service ticket
+ // `getTicket` uses the unserializable method and Objects may throw Throwables in their
+ // unserialization handlers.
+ $serviceTicket = $this->ticketStore->getTicket($ticket);
+ // Delete the ticket
+ $this->ticketStore->deleteTicket($ticket);
+ } catch (\Exception $e) {
+ Logger::error('casserver:validate: internal server error. ' . var_export($e->getMessage(), true));
+ return new Response(
+ $this->cas10Protocol->getValidateFailureResponse(),
+ Response::HTTP_INTERNAL_SERVER_ERROR,
+ );
+ }
+
+ $failed = false;
+ $message = '';
+ if (empty($serviceTicket)) {
+ // No ticket
+ $message = 'ticket: ' . var_export($ticket, true) . ' not recognized';
+ $failed = true;
+ } elseif (!$this->ticketFactory->isServiceTicket($serviceTicket)) {
+ // This is not a service ticket
+ $message = 'ticket: ' . var_export($ticket, true) . ' is not a service ticket';
+ $failed = true;
+ } elseif ($this->ticketFactory->isExpired($serviceTicket)) {
+ // the ticket has expired
+ $message = 'Ticket has ' . var_export($ticket, true) . ' expired';
+ $failed = true;
+ } elseif ($this->sanitize($serviceTicket['service']) !== $this->sanitize($service)) {
+ // The service url we passed to the query parameters does not match the one in the ticket.
+ $message = 'Mismatching service parameters: expected ' .
+ var_export($serviceTicket['service'], true) .
+ ' but was: ' . var_export($service, true);
+ $failed = true;
+ } elseif ($forceAuthn && !$serviceTicket['forceAuthn']) {
+ // If `forceAuthn` is required but not set in the ticket
+ $message = 'Ticket was issued from single sign on session';
+ $failed = true;
+ }
+
+ if ($failed) {
+ Logger::error('casserver:validate: ' . $message);
+ return new Response(
+ $this->cas10Protocol->getValidateFailureResponse(),
+ Response::HTTP_BAD_REQUEST,
+ );
+ }
+
+ // Fail if the username is not present in the ticket
+ if (empty($serviceTicket['userName'])) {
+ return new Response(
+ $this->cas10Protocol->getValidateFailureResponse(),
+ Response::HTTP_BAD_REQUEST,
+ );
+ }
+
+ // Successful validation
+ return new Response(
+ $this->cas10Protocol->getValidateSuccessResponse($serviceTicket['userName']),
+ Response::HTTP_OK,
+ );
+ }
+
+ /**
+ * Used by the unit tests
+ *
+ * @return TicketStore
+ */
+ public function getTicketStore(): TicketStore
+ {
+ return $this->ticketStore;
+ }
+}
diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php
new file mode 100644
index 00000000..6b875ed5
--- /dev/null
+++ b/src/Controller/Cas20Controller.php
@@ -0,0 +1,240 @@
+casConfig = ($casConfig === null || $casConfig === $sspConfig)
+ ? Configuration::getConfig('module_casserver.php') : $casConfig;
+ $this->cas20Protocol = new Cas20($this->casConfig);
+ /* Instantiate ticket factory */
+ $this->ticketFactory = new TicketFactory($this->casConfig);
+ /* Instantiate ticket store */
+ $ticketStoreConfig = $this->casConfig->getOptionalValue(
+ 'ticketstore',
+ ['class' => 'casserver:FileSystemTicketStore'],
+ );
+ $ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket');
+ $this->ticketStore = $ticketStore ?? new $ticketStoreClass($this->casConfig);
+ $this->httpUtils = $httpUtils ?? new Utils\HTTP();
+ }
+
+ /**
+ * @param Request $request
+ * @param string|null $TARGET Query parameter name for "service" used by older CAS clients'
+ * @param bool $renew [OPTIONAL] - if this parameter is set, ticket validation will only succeed
+ * if the service ticket was issued from the presentation of the user’s primary
+ * credentials. It will fail if the ticket was issued from a single sign-on session.
+ * @param string|null $ticket [REQUIRED] - the service ticket issued by /login
+ * @param string|null $service [REQUIRED] - the identifier of the service for which the ticket was issued
+ * @param string|null $pgtUrl [OPTIONAL] - the URL of the proxy callback
+ *
+ * @return XmlResponse
+ */
+ public function serviceValidate(
+ Request $request,
+ #[MapQueryParameter] ?string $TARGET = null,
+ #[MapQueryParameter] bool $renew = false,
+ #[MapQueryParameter] ?string $ticket = null,
+ #[MapQueryParameter] ?string $service = null,
+ #[MapQueryParameter] ?string $pgtUrl = null,
+ ): XmlResponse {
+ return $this->validate(
+ request: $request,
+ method: 'serviceValidate',
+ renew: $renew,
+ target: $TARGET,
+ ticket: $ticket,
+ service: $service,
+ pgtUrl: $pgtUrl,
+ );
+ }
+
+ /**
+ * /proxy provides proxy tickets to services that have
+ * acquired proxy-granting tickets and will be proxying authentication to back-end services.
+ *
+ * @param Request $request
+ * @param string|null $targetService [REQUIRED] - the service identifier of the back-end service.
+ * @param string|null $pgt [REQUIRED] - the proxy-granting ticket acquired by the service
+ * during service ticket or proxy ticket validation.
+ *
+ * @return XmlResponse
+ * @throws \ErrorException
+ */
+ public function proxy(
+ Request $request,
+ #[MapQueryParameter] ?string $targetService = null,
+ #[MapQueryParameter] ?string $pgt = null,
+ ): XmlResponse {
+ // NOTE: Here we do not override the configuration
+ $legal_target_service_urls = $this->casConfig->getOptionalValue('legal_target_service_urls', []);
+ // Fail if
+ $message = match (true) {
+ // targetService parameter is not defined
+ $targetService === null => 'Missing target service parameter [targetService]',
+ // pgt parameter is not defined
+ $pgt === null => 'Missing proxy granting ticket parameter: [pgt]',
+ !$this->checkServiceURL($this->sanitize($targetService), $legal_target_service_urls) =>
+ "Target service parameter not listed as a legal service: [targetService] = {$targetService}",
+ default => null,
+ };
+
+ if (!empty($message)) {
+ return new XmlResponse(
+ (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_REQUEST, $message),
+ Response::HTTP_BAD_REQUEST,
+ );
+ }
+
+ // Get the ticket
+ $proxyGrantingTicket = $this->ticketStore->getTicket($pgt);
+ $message = match (true) {
+ // targetService parameter is not defined
+ $proxyGrantingTicket === null => "Ticket {$pgt} not recognized",
+ // pgt parameter is not defined
+ !$this->ticketFactory->isProxyGrantingTicket($proxyGrantingTicket)
+ => "Not a valid proxy granting ticket id: {$pgt}",
+ default => null,
+ };
+
+ if (!empty($message)) {
+ return new XmlResponse(
+ (string)$this->cas20Protocol->getValidateFailureResponse('BAD_PGT', $message),
+ Response::HTTP_BAD_REQUEST,
+ );
+ }
+
+ // Get the session id from the ticket
+ $sessionTicket = $this->ticketStore->getTicket($proxyGrantingTicket['sessionId']);
+
+ if (
+ $sessionTicket === null
+ || $this->ticketFactory->isSessionTicket($sessionTicket) === false
+ || $this->ticketFactory->isExpired($sessionTicket)
+ ) {
+ $message = "Ticket {$pgt} has expired";
+ Logger::debug('casserver:' . $message);
+
+ return new XmlResponse(
+ (string)$this->cas20Protocol->getValidateFailureResponse('BAD_PGT', $message),
+ Response::HTTP_BAD_REQUEST,
+ );
+ }
+
+ $proxyTicket = $this->ticketFactory->createProxyTicket(
+ [
+ 'service' => $targetService,
+ 'forceAuthn' => $proxyGrantingTicket['forceAuthn'],
+ 'attributes' => $proxyGrantingTicket['attributes'],
+ 'proxies' => $proxyGrantingTicket['proxies'],
+ 'sessionId' => $proxyGrantingTicket['sessionId'],
+ ],
+ );
+
+ $this->ticketStore->addTicket($proxyTicket);
+
+ return new XmlResponse(
+ (string)$this->cas20Protocol->getProxySuccessResponse($proxyTicket['id']),
+ Response::HTTP_OK,
+ );
+ }
+
+ /**
+ * @param Request $request
+ * @param string|null $TARGET Query parameter name for "service" used by older CAS clients'
+ * @param bool $renew [OPTIONAL] - if this parameter is set, ticket validation will only succeed
+ * if the service ticket was issued from the presentation of the user’s primary
+ * credentials. It will fail if the ticket was issued from a single sign-on session.
+ * @param string|null $ticket [REQUIRED] - the service ticket issued by /login
+ * @param string|null $service [REQUIRED] - the identifier of the service for which the ticket was issued
+ * @param string|null $pgtUrl [OPTIONAL] - the URL of the proxy callback
+ *
+ * @return XmlResponse
+ */
+ public function proxyValidate(
+ Request $request,
+ #[MapQueryParameter] ?string $TARGET = null,
+ #[MapQueryParameter] bool $renew = false,
+ #[MapQueryParameter] ?string $ticket = null,
+ #[MapQueryParameter] ?string $service = null,
+ #[MapQueryParameter] ?string $pgtUrl = null,
+ ): XmlResponse {
+ return $this->validate(
+ request: $request,
+ method: 'proxyValidate',
+ renew: $renew,
+ target: $TARGET,
+ ticket: $ticket,
+ service: $service,
+ pgtUrl: $pgtUrl,
+ );
+ }
+
+ /**
+ * Used by the unit tests
+ *
+ * @return TicketStore
+ */
+ public function getTicketStore(): TicketStore
+ {
+ return $this->ticketStore;
+ }
+}
diff --git a/src/Controller/Cas30Controller.php b/src/Controller/Cas30Controller.php
new file mode 100644
index 00000000..6765e7a5
--- /dev/null
+++ b/src/Controller/Cas30Controller.php
@@ -0,0 +1,133 @@
+casConfig = ($casConfig === null || $casConfig === $sspConfig)
+ ? Configuration::getConfig('module_casserver.php') : $casConfig;
+ $this->cas20Protocol = new Cas20($this->casConfig);
+ $this->ticketValidator = $ticketValidator ?? new TicketValidator($this->casConfig);
+ $this->validateResponder = new SamlValidateResponder();
+ }
+
+
+ /**
+ * POST /casserver/samlValidate?TARGET=
+ * Host: cas.example.com
+ * Content-Length: 491
+ * Content-Type: text/xml
+ *
+ * @param Request $request
+ * @param string $TARGET URL encoded service identifier of the back-end service.
+ *
+ * @throw SimpleSAML\SAML11\Exception\ProtocolViolationException
+ * @throw SimpleSAML\XML\Exception\MissingAttributeException
+ * @throw \RuntimeException
+ * @return XmlResponse
+ * @link https://apereo.github.io/cas/7.1.x/protocol/CAS-Protocol-Specification.html#42-samlvalidate-cas-30
+ */
+ public function samlValidate(
+ Request $request,
+ #[MapQueryParameter] string $TARGET,
+ ): XmlResponse {
+ $postBody = $request->getContent();
+ if (empty($postBody)) {
+ throw new \RuntimeException('samlValidate expects a soap body.');
+ }
+
+ // SAML request values
+ //
+ // samlp:Request
+ // - RequestID [REQUIRED] - unique identifier for the request
+ // - IssueInstant [REQUIRED] - timestamp of the request
+ // samlp:AssertionArtifact [REQUIRED] - the valid CAS Service
+
+ $documentBody = DOMDocumentFactory::fromString($postBody);
+ $envelope = Envelope::fromXML($documentBody->documentElement);
+
+ // The SOAP Envelope must have only one ticket
+ $elements = $envelope->getBody()->getElements();
+ if (count($elements) > 1 || count($elements) < 1) {
+ throw new ProtocolViolationException('samlValidate expects a soap body with only one ticket.');
+ }
+
+ // Request Element
+ $samlpRequestParsed = SamlRequest::fromXML($elements[0]->getXML());
+ // Assertion Artifact Element
+ $assertionArtifactParsed = $samlpRequestParsed->getRequest()[0];
+
+ $ticketId = $assertionArtifactParsed->getContent();
+ Logger::debug('samlvalidate: Checking ticket ' . $ticketId);
+
+ try {
+ // validateAndDeleteTicket might throw a CasException. In order to avoid third party modules
+ // dependencies, we will catch and rethrow the Exception.
+ $ticket = $this->ticketValidator->validateAndDeleteTicket($ticketId, $TARGET);
+ } catch (\Exception $e) {
+ throw new \RuntimeException($e->getMessage());
+ }
+ if (!\is_array($ticket)) {
+ throw new \RuntimeException('Error loading ticket');
+ }
+
+ $response = $this->validateResponder->convertToSaml($ticket);
+ $soap = $this->validateResponder->wrapInSoap($response);
+
+ return new XmlResponse(
+ (string)$soap,
+ Response::HTTP_OK,
+ );
+ }
+}
diff --git a/src/Controller/LoggedInController.php b/src/Controller/LoggedInController.php
new file mode 100644
index 00000000..e4049f65
--- /dev/null
+++ b/src/Controller/LoggedInController.php
@@ -0,0 +1,45 @@
+config = $config ?? Configuration::getInstance();
+ }
+
+ /**
+ * Show Log out view.
+ *
+ * @param Request $request
+ * @return Response
+ * @throws \Exception
+ */
+ public function main(Request $request): Response
+ {
+ session_cache_limiter('nocache');
+ return new Template($this->config, 'casserver:loggedIn.twig');
+ }
+}
diff --git a/src/Controller/LoggedOutController.php b/src/Controller/LoggedOutController.php
new file mode 100644
index 00000000..b645a3fb
--- /dev/null
+++ b/src/Controller/LoggedOutController.php
@@ -0,0 +1,48 @@
+config = $config ?? Configuration::getInstance();
+ }
+
+ /**
+ * Show Log out view.
+ *
+ * @param Request $request
+ * @return Response
+ * @throws \Exception
+ */
+ public function main(Request $request): Response
+ {
+ $t = new Template($this->config, 'casserver:loggedOut.twig');
+ if ($request->query->has('url')) {
+ $t->data['url'] = $request->query->get('url');
+ }
+ return $t;
+ }
+}
diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php
new file mode 100644
index 00000000..b678ca99
--- /dev/null
+++ b/src/Controller/LoginController.php
@@ -0,0 +1,453 @@
+casConfig = ($casConfig === null || $casConfig === $sspConfig)
+ ? Configuration::getConfig('module_casserver.php') : $casConfig;
+ // Saml Validate Responsder
+ $this->samlValidateResponder = new SamlValidateResponder();
+ // Service Validator needs the generic casserver configuration.
+ $this->serviceValidator = new ServiceValidator($this->casConfig);
+ $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource'));
+ $this->httpUtils = $httpUtils ?? new Utils\HTTP();
+ }
+
+ /**
+ *
+ * @param Request $request
+ * @param bool $renew
+ * @param bool $gateway
+ * @param string|null $service
+ * @param string|null $TARGET Query parameter name for "service" used by older CAS clients'
+ * @param string|null $scope
+ * @param string|null $language
+ * @param string|null $entityId
+ * @param string|null $debugMode
+ * @param string|null $method
+ *
+ * @return RunnableResponse|Template
+ * @throws ConfigurationError
+ * @throws NoState
+ */
+ public function login(
+ Request $request,
+ #[MapQueryParameter] bool $renew = false,
+ #[MapQueryParameter] bool $gateway = false,
+ #[MapQueryParameter] ?string $service = null,
+ #[MapQueryParameter] ?string $TARGET = null,
+ #[MapQueryParameter] ?string $scope = null,
+ #[MapQueryParameter] ?string $language = null,
+ #[MapQueryParameter] ?string $entityId = null,
+ #[MapQueryParameter] ?string $debugMode = null,
+ #[MapQueryParameter] ?string $method = null,
+ ): RunnableResponse|Template {
+ $forceAuthn = $renew;
+ $serviceUrl = $service ?? $TARGET ?? null;
+ $redirect = !(isset($method) && $method === 'POST');
+
+ // Set initial configurations, or fail
+ $this->handleServiceConfiguration($serviceUrl);
+ // Instantiate the classes that rely on the override configuration.
+ // We do not do this in the constructor since we do not have the correct values yet.
+ $this->instantiateClassDependencies();
+ $this->handleScope($scope);
+ $this->handleLanguage($language);
+
+ // Get the ticket from the session
+ $session = $this->getSession();
+ $sessionTicket = $this->ticketStore->getTicket($session->getSessionId());
+ $sessionRenewId = $sessionTicket['renewId'] ?? null;
+ $requestRenewId = $this->getRequestParam($request, 'renewId');
+ // if this parameter is true, single sign-on will be bypassed and authentication will be enforced
+ $requestForceAuthenticate = $forceAuthn && $sessionRenewId !== $requestRenewId;
+
+ if ($request->query->has(ProcessingChain::AUTHPARAM)) {
+ $this->authProcId = $request->query->get(ProcessingChain::AUTHPARAM);
+ }
+
+ // Construct the ReturnTo URL
+ // This will be used to come back from the AuthSource login or from the Processing Chain
+ $returnToUrl = $this->getReturnUrl($request, $sessionTicket);
+
+ // Authenticate
+ if (
+ $requestForceAuthenticate || !$this->authSource->isAuthenticated()
+ ) {
+ $params = [
+ 'ForceAuthn' => $forceAuthn,
+ 'isPassive' => $gateway,
+ 'ReturnTo' => $returnToUrl,
+ ];
+
+ if (isset($entityId)) {
+ $params['saml:idp'] = $entityId;
+ }
+
+ if (isset($this->idpList)) {
+ if (sizeof($this->idpList) > 1) {
+ $params['saml:IDPList'] = $this->idpList;
+ } else {
+ $params['saml:idp'] = $this->idpList[0];
+ }
+ }
+
+ /*
+ * REDIRECT TO AUTHSOURCE LOGIN
+ * */
+ return new RunnableResponse(
+ [$this->authSource, 'login'],
+ [$params],
+ );
+ }
+
+ // We are Authenticated.
+
+ $sessionExpiry = $this->authSource->getAuthData('Expire');
+ // Create a new ticket if we do not have one alreday, or if we are in a forced Authentitcation mode
+ if (!\is_array($sessionTicket) || $forceAuthn) {
+ $sessionTicket = $this->ticketFactory->createSessionTicket($session->getSessionId(), $sessionExpiry);
+ $this->ticketStore->addTicket($sessionTicket);
+ }
+
+ /*
+ * We are done. REDIRECT TO LOGGEDIN
+ * */
+ if (!isset($serviceUrl) && $this->authProcId === null) {
+ $loggedInUrl = Module::getModuleURL('casserver/loggedIn');
+ return new RunnableResponse(
+ [$this->httpUtils, 'redirectTrustedURL'],
+ [$loggedInUrl, $this->postAuthUrlParameters],
+ );
+ }
+
+ // Get the state.
+ $state = $this->getState();
+ $state['ReturnTo'] = $returnToUrl;
+ if ($this->authProcId !== null) {
+ $state[ProcessingChain::AUTHPARAM] = $this->authProcId;
+ }
+ // Attribute Handler
+ $mappedAttributes = $this->attributeExtractor->extractUserAndAttributes($state);
+ $serviceTicket = $this->ticketFactory->createServiceTicket([
+ 'service' => $serviceUrl,
+ 'forceAuthn' => $forceAuthn,
+ 'userName' => $mappedAttributes['user'],
+ 'attributes' => $mappedAttributes['attributes'],
+ 'proxies' => [],
+ 'sessionId' => $sessionTicket['id'],
+ ]);
+ $this->ticketStore->addTicket($serviceTicket);
+
+ // Check if we are in debug mode.
+ if ($debugMode !== null && $this->casConfig->getOptionalBoolean('debugMode', false)) {
+ [$templateName, $statusCode, $DebugModeXmlString] = $this->handleDebugMode(
+ $request,
+ $debugMode,
+ $serviceTicket,
+ );
+ $t = new Template($this->sspConfig, (string)$templateName);
+ $t->data['debugMode'] = $debugMode === 'true' ? 'Default' : $debugMode;
+ if (!str_contains('error', (string)$templateName)) {
+ $t->data['DebugModeXml'] = $DebugModeXmlString;
+ }
+ $t->data['statusCode'] = $statusCode;
+ // Return an HTML View that renders the result
+ return $t;
+ }
+
+ $ticketName = $this->calculateTicketName($service);
+ $this->postAuthUrlParameters[$ticketName] = $serviceTicket['id'];
+
+ // GET
+ if ($redirect) {
+ return new RunnableResponse(
+ [$this->httpUtils, 'redirectTrustedURL'],
+ [$serviceUrl, $this->postAuthUrlParameters],
+ );
+ }
+ // POST
+ return new RunnableResponse(
+ [$this->httpUtils, 'submitPOSTData'],
+ [$serviceUrl, $this->postAuthUrlParameters],
+ );
+ }
+
+ /**
+ * @param Request $request
+ * @param string|null $debugMode
+ * @param array $serviceTicket
+ *
+ * @return array []
+ */
+ public function handleDebugMode(
+ Request $request,
+ ?string $debugMode,
+ array $serviceTicket,
+ ): array {
+ // Check if the debugMode is supported
+ if (!\in_array($debugMode, self::DEBUG_MODES, true)) {
+ return ['casserver:error.twig', Response::HTTP_BAD_REQUEST, 'Invalid/Unsupported Debug Mode'];
+ }
+
+ if ($debugMode === 'true') {
+ // Service validate CAS20
+ $xmlResponse = $this->validate(
+ request: $request,
+ method: 'serviceValidate',
+ renew: $request->get('renew', false),
+ target: $request->get('target'),
+ ticket: $serviceTicket['id'],
+ service: $request->get('service'),
+ pgtUrl: $request->get('pgtUrl'),
+ );
+ return ['casserver:validate.twig', $xmlResponse->getStatusCode(), $xmlResponse->getContent()];
+ }
+
+ // samlValidate Mode
+ $samlResponse = $this->samlValidateResponder->convertToSaml($serviceTicket);
+ return [
+ 'casserver:validate.twig',
+ Response::HTTP_OK,
+ (string)$this->samlValidateResponder->wrapInSoap($samlResponse),
+ ];
+ }
+
+ /**
+ * @return array|null
+ * @throws \SimpleSAML\Error\NoState
+ */
+ public function getState(): ?array
+ {
+ // If we come from an authproc filter, we will load the state from the stateId.
+ // If not, we will get the state from the AuthSource Data
+
+ return $this->authProcId !== null ?
+ $this->attributeExtractor->manageState($this->authProcId) :
+ $this->authSource->getAuthDataArray();
+ }
+
+ /**
+ * Construct the ticket name
+ *
+ * @param string|null $service
+ *
+ * @return string
+ */
+ public function calculateTicketName(?string $service): string
+ {
+ $defaultTicketName = $service !== null ? 'ticket' : 'SAMLart';
+ return $this->casConfig->getOptionalValue('ticketName', $defaultTicketName);
+ }
+
+ /**
+ * @param Request $request
+ * @param array|null $sessionTicket
+ *
+ * @return string
+ */
+ public function getReturnUrl(Request $request, ?array $sessionTicket): string
+ {
+ // Parse the query parameters and return them in an array
+ $query = $this->parseQueryParameters($request, $sessionTicket);
+ // Construct the ReturnTo URL
+ return $this->httpUtils->getSelfURLNoQuery() . '?' . http_build_query($query);
+ }
+
+ /**
+ * @param string|null $serviceUrl
+ *
+ * @return void
+ * @throws \RuntimeException
+ */
+ public function handleServiceConfiguration(?string $serviceUrl): void
+ {
+ if ($serviceUrl === null) {
+ return;
+ }
+ $serviceCasConfig = $this->serviceValidator->checkServiceURL($this->sanitize($serviceUrl));
+ if (!isset($serviceCasConfig)) {
+ $message = 'Service parameter provided to CAS server is not listed as a legal service: [service] = ' .
+ var_export($serviceUrl, true);
+ Logger::debug('casserver:' . $message);
+
+ throw new \RuntimeException($message);
+ }
+
+ // Override the cas configuration to use for this service
+ $this->casConfig = $serviceCasConfig;
+ }
+
+ /**
+ * @param string|null $language
+ *
+ * @return void
+ */
+ public function handleLanguage(?string $language): void
+ {
+ // If null, do nothing
+ if ($language === null) {
+ return;
+ }
+
+ $this->postAuthUrlParameters['language'] = $language;
+ }
+
+ /**
+ * @param string|null $scope
+ *
+ * @return void
+ * @throws \RuntimeException
+ */
+ public function handleScope(?string $scope): void
+ {
+ // If null, do nothing
+ if ($scope === null) {
+ return;
+ }
+
+ // Get the scopes from the configuration
+ $scopes = $this->casConfig->getOptionalValue('scopes', []);
+
+ // Fail
+ if (!isset($scopes[$scope])) {
+ $message = 'Scope parameter provided to CAS server is not listed as legal scope: [scope] = ' .
+ var_export($scope, true);
+ Logger::debug('casserver:' . $message);
+
+ throw new \RuntimeException($message);
+ }
+
+ // Set the idplist from the scopes
+ $this->idpList = $scopes[$scope];
+ }
+
+ /**
+ * Get the Session
+ *
+ * @return Session|null
+ * @throws \Exception
+ */
+ public function getSession(): ?Session
+ {
+ return Session::getSessionFromRequest();
+ }
+
+ /**
+ * @return TicketStore
+ */
+ public function getTicketStore(): TicketStore
+ {
+ return $this->ticketStore;
+ }
+
+ /**
+ * @return void
+ * @throws \Exception
+ */
+ private function instantiateClassDependencies(): void
+ {
+ $this->cas20Protocol = new Cas20($this->casConfig);
+
+ /* Instantiate ticket factory */
+ $this->ticketFactory = new TicketFactory($this->casConfig);
+ /* Instantiate ticket store */
+ $ticketStoreConfig = $this->casConfig->getOptionalValue(
+ 'ticketstore',
+ ['class' => 'casserver:FileSystemTicketStore'],
+ );
+ $ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket');
+ // Ticket Store
+ $this->ticketStore = new $ticketStoreClass($this->casConfig);
+ // Processing Chain Factory
+ $processingChainFactory = new ProcessingChainFactory($this->casConfig);
+ // Attribute Extractor
+ $this->attributeExtractor = new AttributeExtractor($this->casConfig, $processingChainFactory);
+ }
+}
diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php
new file mode 100644
index 00000000..c0922a2d
--- /dev/null
+++ b/src/Controller/LogoutController.php
@@ -0,0 +1,157 @@
+casConfig = ($casConfig === null || $casConfig === $sspConfig)
+ ? Configuration::getConfig('module_casserver.php') : $casConfig;
+ $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource'));
+ $this->httpUtils = $httpUtils ?? new Utils\HTTP();
+
+ /* Instantiate ticket factory */
+ $this->ticketFactory = new TicketFactory($this->casConfig);
+ /* Instantiate ticket store */
+ $ticketStoreConfig = $this->casConfig->getOptionalValue(
+ 'ticketstore',
+ ['class' => 'casserver:FileSystemTicketStore'],
+ );
+ $ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket');
+ $this->ticketStore = new $ticketStoreClass($this->casConfig);
+ }
+
+ /**
+ *
+ * @param Request $request
+ * @param string|null $url
+ *
+ * @return RunnableResponse
+ */
+ public function logout(
+ Request $request,
+ #[MapQueryParameter] ?string $url = null,
+ ): RunnableResponse {
+ if (!$this->casConfig->getOptionalValue('enable_logout', false)) {
+ $this->handleExceptionThrown('Logout not allowed');
+ }
+
+ // Skip Logout Page configuration
+ $skipLogoutPage = $this->casConfig->getOptionalValue('skip_logout_page', false);
+
+ if ($skipLogoutPage && $url === null) {
+ $this->handleExceptionThrown('Required URL query parameter [url] not provided. (CAS Server)');
+ }
+
+ // Construct the logout redirect url
+ if ($skipLogoutPage) {
+ $logoutRedirectUrl = $url;
+ $params = [];
+ } else {
+ $logoutRedirectUrl = Module::getModuleURL('casserver/loggedOut');
+ $params = $url === null ? []
+ : ['url' => $url];
+ }
+
+ // Delete the ticket from the session
+ $session = $this->getSession();
+ if ($session !== null) {
+ $this->ticketStore->deleteTicket($session->getSessionId());
+ }
+
+ // Redirect
+ if (!$this->authSource->isAuthenticated()) {
+ return new RunnableResponse([$this->httpUtils, 'redirectTrustedURL'], [$logoutRedirectUrl, $params]);
+ }
+
+ // Logout and redirect
+ return new RunnableResponse(
+ [$this->authSource, 'logout'],
+ [$logoutRedirectUrl],
+ );
+ }
+
+ /**
+ * @return TicketStore
+ */
+ public function getTicketStore(): TicketStore
+ {
+ return $this->ticketStore;
+ }
+
+ /**
+ * @param string $message
+ *
+ * @return void
+ */
+ protected function handleExceptionThrown(string $message): void
+ {
+ Logger::debug('casserver:' . $message);
+ throw new \RuntimeException($message);
+ }
+
+ /**
+ * Get the Session
+ *
+ * @return Session|null
+ */
+ protected function getSession(): ?Session
+ {
+ return Session::getSession();
+ }
+}
diff --git a/src/Controller/Traits/TicketValidatorTrait.php b/src/Controller/Traits/TicketValidatorTrait.php
new file mode 100644
index 00000000..f4d7709c
--- /dev/null
+++ b/src/Controller/Traits/TicketValidatorTrait.php
@@ -0,0 +1,184 @@
+cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message),
+ Response::HTTP_BAD_REQUEST,
+ );
+ }
+
+ try {
+ // Get the service ticket
+ // `getTicket` uses the unserializable method and Objects may throw "Throwables" in their
+ // un-serialization handlers.
+ $serviceTicket = $this->ticketStore->getTicket($ticket);
+ } catch (\Exception $e) {
+ $messagePostfix = '';
+ if (!empty($e->getMessage())) {
+ $messagePostfix = ': ' . var_export($e->getMessage(), true);
+ }
+ $message = 'casserver:serviceValidate: internal server error' . $messagePostfix;
+ Logger::error(__METHOD__ . '::' . $message);
+
+ return new XmlResponse(
+ (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INTERNAL_ERROR, $message),
+ Response::HTTP_INTERNAL_SERVER_ERROR,
+ );
+ }
+
+ $failed = false;
+ $message = '';
+ // Below, we do not have a ticket or the ticket does not meet the very basic criteria that allow
+ // any further handling
+ if (empty($serviceTicket)) {
+ // No ticket
+ $message = 'Ticket ' . var_export($ticket, true) . ' not recognized';
+ $failed = true;
+ } elseif ($method === 'proxyValidate' && !$this->ticketFactory->isProxyTicket($serviceTicket)) {
+ // proxyValidate but not a proxy ticket
+ $message = 'Ticket ' . var_export($ticket, true) . ' is not a proxy ticket.';
+ $failed = true;
+ } elseif ($method === 'serviceValidate' && !$this->ticketFactory->isServiceTicket($serviceTicket)) {
+ // serviceValidate but not a service ticket
+ $message = 'Ticket ' . var_export($ticket, true) . ' is not a service ticket.';
+ $failed = true;
+ }
+
+ if ($failed) {
+ $finalMessage = 'casserver:validate: ' . $message;
+ Logger::error(__METHOD__ . '::' . $finalMessage);
+
+ return new XmlResponse(
+ (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message),
+ Response::HTTP_BAD_REQUEST,
+ );
+ }
+
+ // Delete the ticket
+ $this->ticketStore->deleteTicket($ticket);
+
+ // Check if the ticket
+ // - has expired
+ // - does not pass sanitization
+ // - forceAutnn criteria are not met
+ if ($this->ticketFactory->isExpired($serviceTicket)) {
+ // the ticket has expired
+ $message = 'Ticket ' . var_export($ticket, true) . ' has expired';
+ $failed = true;
+ } elseif ($this->sanitize($serviceTicket['service']) !== $this->sanitize($serviceUrl)) {
+ // The service url we passed to the query parameters does not match the one in the ticket.
+ $message = 'Mismatching service parameters: expected ' .
+ var_export($serviceTicket['service'], true) .
+ ' but was: ' . var_export($serviceUrl, true);
+ $failed = true;
+ } elseif ($forceAuthn && !$serviceTicket['forceAuthn']) {
+ // If `forceAuthn` is required but not set in the ticket
+ $message = 'Ticket was issued from single sign on session';
+ $failed = true;
+ }
+
+ if ($failed) {
+ $finalMessage = 'casserver:validate: ' . $message;
+ Logger::error(__METHOD__ . '::' . $finalMessage);
+
+ return new XmlResponse(
+ (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message),
+ Response::HTTP_BAD_REQUEST,
+ );
+ }
+
+ $attributes = $serviceTicket['attributes'];
+ $this->cas20Protocol->setAttributes($attributes);
+
+ if (isset($pgtUrl)) {
+ $sessionTicket = $this->ticketStore->getTicket($serviceTicket['sessionId']);
+ if (
+ $sessionTicket !== null
+ && $this->ticketFactory->isSessionTicket($sessionTicket)
+ && !$this->ticketFactory->isExpired($sessionTicket)
+ ) {
+ $proxyGrantingTicket = $this->ticketFactory->createProxyGrantingTicket(
+ [
+ 'userName' => $serviceTicket['userName'],
+ 'attributes' => $attributes,
+ 'forceAuthn' => false,
+ 'proxies' => array_merge(
+ [$serviceUrl],
+ $serviceTicket['proxies'],
+ ),
+ 'sessionId' => $serviceTicket['sessionId'],
+ ],
+ );
+ try {
+ // Here we assume that the fetch will throw on any error.
+ // The generation of the proxy-granting-ticket or the corresponding proxy granting ticket IOU may
+ // fail due to the proxy callback url failing to meet the minimum security requirements such as
+ // failure to establish trust between peers or unresponsiveness of the endpoint, etc.
+ // In case of failure, no proxy-granting ticket will be issued and the CAS service response
+ // as described in Section 2.5.2 MUST NOT contain a block.
+ // At this point, the issuance of a proxy-granting ticket is halted and service ticket
+ // validation will fail.
+ $data = $this->httpUtils->fetch(
+ $pgtUrl . '?pgtIou=' . $proxyGrantingTicket['iou'] . '&pgtId=' . $proxyGrantingTicket['id'],
+ );
+ Logger::debug(__METHOD__ . '::data: ' . var_export($data, true));
+ $this->cas20Protocol->setProxyGrantingTicketIOU($proxyGrantingTicket['iou']);
+ $this->ticketStore->addTicket($proxyGrantingTicket);
+ } catch (\Exception $e) {
+ return new XmlResponse(
+ (string)$this->cas20Protocol->getValidateFailureResponse(
+ C::ERR_INVALID_SERVICE,
+ 'Proxy callback url is failing.',
+ ),
+ Response::HTTP_BAD_REQUEST,
+ );
+ }
+ }
+ }
+
+ return new XmlResponse(
+ (string)$this->cas20Protocol->getValidateSuccessResponse($serviceTicket['userName']),
+ Response::HTTP_OK,
+ );
+ }
+}
diff --git a/src/Controller/Traits/UrlTrait.php b/src/Controller/Traits/UrlTrait.php
new file mode 100644
index 00000000..a2192f1d
--- /dev/null
+++ b/src/Controller/Traits/UrlTrait.php
@@ -0,0 +1,78 @@
+ $legal_service_urls]);
+ $serviceValidator = new ServiceValidator($config);
+ return $serviceValidator->checkServiceURL($service) !== null;
+ }
+
+ /**
+ * @param string $parameter
+ * @return string
+ */
+ public function sanitize(string $parameter): string
+ {
+ return TicketValidator::sanitize($parameter);
+ }
+
+ /**
+ * Parse the query Parameters from $_GET global and return them in an array.
+ *
+ * @param Request $request
+ * @param array|null $sessionTicket
+ *
+ * @return array
+ */
+ public function parseQueryParameters(Request $request, ?array $sessionTicket): array
+ {
+ $forceAuthn = $this->getRequestParam($request, 'renew');
+ $sessionRenewId = !empty($sessionTicket['renewId']) ? $sessionTicket['renewId'] : null;
+
+ $queryParameters = $request->query->all();
+ $requestParameters = $request->request->all();
+
+ $query = array_merge($requestParameters, $queryParameters);
+
+ if ($sessionRenewId && $forceAuthn) {
+ $query['renewId'] = $sessionRenewId;
+ }
+
+ if (isset($query['language'])) {
+ $query['language'] = is_string($query['language']) ? $query['language'] : null;
+ }
+
+ return $query;
+ }
+
+ /**
+ * @param Request $request
+ * @param string $paramName
+ *
+ * @return mixed
+ */
+ public function getRequestParam(Request $request, string $paramName): mixed
+ {
+ return $request->query->get($paramName) ?? $request->request->get($paramName) ?? null;
+ }
+}
diff --git a/src/Http/XmlResponse.php b/src/Http/XmlResponse.php
new file mode 100644
index 00000000..23e00c0e
--- /dev/null
+++ b/src/Http/XmlResponse.php
@@ -0,0 +1,17 @@
+ 'text/xml; charset=UTF-8',
+ ]));
+ }
+}
diff --git a/templates/error.twig b/templates/error.twig
new file mode 100644
index 00000000..67517a17
--- /dev/null
+++ b/templates/error.twig
@@ -0,0 +1,8 @@
+{% set pagetitle = 'Debug Mode'|trans %}
+
+{% extends "base.twig" %}
+
+{% block content -%}
+{{ pagetitle }} - {{ debugMode|trans }}
+Debug Method NOT Supported
+{% endblock %}
diff --git a/templates/validate.twig b/templates/validate.twig
new file mode 100644
index 00000000..6c42bf36
--- /dev/null
+++ b/templates/validate.twig
@@ -0,0 +1,30 @@
+{% set pagetitle = 'Debug Mode'|trans %}
+{% set copied = '{copied}'|trans %}
+{% set copy = '{copy}'|trans %}
+{% set success = '{success}'|trans %}
+{% set failure = '{failure}'|trans %}
+{% set copyToClipboard = '{copyToClipboard}'|trans %}
+
+{% extends "base.twig" %}
+
+{% block preload %}
+
+{% endblock %}
+
+{% block content -%}
+ {{ pagetitle }} - {{ debugMode|trans }}
+ {%- if statusCode == 200 -%}
+ {{ success }}
+ {%- else -%}
+ {{ failure }}
+ {%- endif -%}
+
+
+
+
+ {{ DebugModeXml }}
+
+{%- endblock %}
diff --git a/tests/public/LoginIntegrationTest.php b/tests/public/LoginIntegrationTest.php
deleted file mode 100644
index a431e985..00000000
--- a/tests/public/LoginIntegrationTest.php
+++ /dev/null
@@ -1,527 +0,0 @@
-server = new BuiltInServer(
- 'configLoader',
- dirname(__FILE__, 3) . '/vendor/simplesamlphp/simplesamlphp/public',
- );
- $this->server_addr = $this->server->start();
- $this->server_pid = $this->server->getPid();
- $this->shared_file = sys_get_temp_dir() . '/' . $this->server_pid . '.lock';
- $this->cookies_file = sys_get_temp_dir() . '/' . $this->server_pid . '.cookies';
-
- $this->updateConfig([
- 'baseurlpath' => '/',
- 'secretsalt' => 'abc123',
-
- 'tempdir' => sys_get_temp_dir(),
- 'loggingdir' => sys_get_temp_dir(),
- 'logging.handler' => 'file',
-
- 'module.enable' => [
- 'casserver' => true,
- 'exampleauth' => true,
- ],
- ]);
- }
-
-
- /**
- * The tear down method that is executed after all tests in this class.
- * Removes the lock file and cookies file
- */
- protected function tearDown(): void
- {
- @unlink($this->shared_file);
- @unlink($this->cookies_file); // remove it if it exists
- $this->server->stop();
- }
-
-
- /**
- * @param array $config
- */
- protected function updateConfig(array $config): void
- {
- @unlink($this->shared_file);
- $file = "shared_file, $file);
-
- Configuration::setPreloadedConfig(Configuration::loadFromArray($config));
- }
-
-
- /**
- * Test authenticating to the login endpoint with no parameters.'
- */
- public function testNoQueryParameters(): void
- {
- $resp = $this->server->get(
- self::$LINK_URL,
- [],
- [
- CURLOPT_COOKIEJAR => $this->cookies_file,
- CURLOPT_COOKIEFILE => $this->cookies_file,
- CURLOPT_FOLLOWLOCATION => true,
- ],
- );
- $this->assertEquals(200, $resp['code']);
-
- $this->assertStringContainsString(
- 'You are logged in.',
- $resp['body'],
- 'Login with no query parameters should make you authenticate and then take you to the login page.',
- );
- }
-
-
- /**
- * Test incorrect service url
- */
- public function testWrongServiceUrl(): void
- {
- $resp = $this->server->get(
- self::$LINK_URL,
- ['service' => 'http://not-legal'],
- [
- CURLOPT_COOKIEJAR => $this->cookies_file,
- CURLOPT_COOKIEFILE => $this->cookies_file,
- CURLOPT_FOLLOWLOCATION => true,
- ],
- );
- $this->assertEquals(500, $resp['code']);
-
- $this->assertStringContainsString(
- 'CAS server is not listed as a legal service',
- $resp['body'],
- 'Illegal cas service urls should be rejected',
- );
- }
-
-
- /**
- * Test a valid service URL
- * @param string $serviceParam The name of the query parameter to use for the service url
- * @param string $ticketParam The name of the query parameter that will contain the ticket
- */
- #[DataProvider('validServiceUrlProvider')]
- public function testValidServiceUrl(string $serviceParam, string $ticketParam): void
- {
- $service_url = 'http://host1.domain:1234/path1';
-
- $this->authenticate();
-
- $resp = $this->server->get(
- self::$LINK_URL,
- [$serviceParam => $service_url],
- [
- CURLOPT_COOKIEJAR => $this->cookies_file,
- CURLOPT_COOKIEFILE => $this->cookies_file,
- ],
- );
- $this->assertEquals(303, $resp['code']);
-
- $this->assertStringStartsWith(
- $service_url . '?' . $ticketParam . '=ST-',
- $resp['headers']['Location'],
- 'Ticket should be part of the redirect.',
- );
-
- // Config ticket can be validated
- $matches = [];
- $this->assertEquals(1, preg_match("@$ticketParam=(.*)@", $resp['headers']['Location'], $matches));
- $ticket = $matches[1];
- $resp = $this->server->get(
- self::$VALIDATE_URL,
- [
- $serviceParam => $service_url,
- 'ticket' => $ticket,
- ],
- [
- CURLOPT_COOKIEJAR => $this->cookies_file,
- CURLOPT_COOKIEFILE => $this->cookies_file,
- ],
- );
-
- $expectedXml = simplexml_load_string(
- file_get_contents(\dirname(__FILE__, 2) . '/resources/xml/testValidServiceUrl.xml'),
- );
-
- $actualXml = simplexml_load_string($resp['body']);
-
- // We will remove the cas:authenticationDate element since we know that it will fail. The dates will not match
- $authenticationNodeToDeleteExpected = $expectedXml->xpath('//cas:authenticationDate')[0];
- $authenticationNodeToDeleteActual = $actualXml->xpath('//cas:authenticationDate')[0];
- unset($authenticationNodeToDeleteExpected[0], $authenticationNodeToDeleteActual[0]);
-
- $this->assertEquals(200, $resp['code']);
-
- $this->assertEquals(
- $expectedXml->xpath('//cas:serviceResponse')[0]->asXML(),
- $actualXml->xpath('//cas:serviceResponse')[0]->asXML(),
- );
- }
-
- public static function validServiceUrlProvider(): array
- {
- return [
- ['service', 'ticket'],
- ['TARGET', 'SAMLart'],
- ];
- }
-
- /**
- * Test changing the ticket name
- */
- public function testValidTicketNameOverride(): void
- {
- $service_url = 'http://changeTicketParam/abc';
-
- $this->authenticate();
-
- $resp = $this->server->get(
- self::$LINK_URL,
- ['TARGET' => $service_url],
- [
- CURLOPT_COOKIEJAR => $this->cookies_file,
- CURLOPT_COOKIEFILE => $this->cookies_file,
- ],
- );
- $this->assertEquals(303, $resp['code']);
-
- $this->assertStringStartsWith(
- $service_url . '?myTicket=ST-',
- $resp['headers']['Location'],
- 'Ticket should be part of the redirect.',
- );
- }
-
-
- /**
- * Test outputting user info instead of redirecting
- */
- public function testDebugOutput(): void
- {
- $service_url = 'http://host1.domain:1234/path1';
- $this->authenticate();
- $resp = $this->server->get(
- self::$LINK_URL,
- ['service' => $service_url, 'debugMode' => 'true'],
- [
- CURLOPT_COOKIEJAR => $this->cookies_file,
- CURLOPT_COOKIEFILE => $this->cookies_file,
- ],
- );
- $this->assertEquals(200, $resp['code']);
-
- $this->assertStringContainsString(
- '<cas:eduPersonPrincipalName>testuser@example.com</cas:eduPersonPrincipalName>',
- $resp['body'],
- 'Attributes should have been printed.',
- );
- }
-
-
- /**
- * Test outputting user info instead of redirecting
- */
- public function testDebugOutputSamlValidate(): void
- {
- $service_url = 'http://host1.domain:1234/path1';
- $this->authenticate();
- $resp = $this->server->get(
- self::$LINK_URL,
- ['service' => $service_url, 'debugMode' => 'samlValidate'],
- [
- CURLOPT_COOKIEJAR => $this->cookies_file,
- CURLOPT_COOKIEFILE => $this->cookies_file,
- ],
- );
- $this->assertEquals(200, $resp['code']);
-
-
- $this->assertStringContainsString(
- 'testuser@example.com</saml:NameIdentifier',
- $resp['body'],
- 'Attributes should have been printed.',
- );
- }
-
-
- /**
- * Test outputting user info instead of redirecting
- */
- public function testAlternateServiceConfigUsed(): void
- {
- $service_url = 'https://override.example.com/somepath';
- $this->authenticate();
- $resp = $this->server->get(
- self::$LINK_URL,
- ['service' => $service_url, 'debugMode' => 'true'],
- [
- CURLOPT_COOKIEJAR => $this->cookies_file,
- CURLOPT_COOKIEFILE => $this->cookies_file,
- ],
- );
- $this->assertEquals(200, $resp['code']);
- $this->assertStringContainsString(
- '<cas:user>testuser</cas:user>',
- $resp['body'],
- 'cas:user attribute should have been overridden',
- );
- $this->assertStringContainsString(
- '<cas:cn>Test User</cas:cn>',
- $resp['body'],
- 'Attributes should have been printed with alternate attribute release',
- );
- }
-
-
- /**
- * test a valid service URL with Post
- */
- public function testValidServiceUrlWithPost(): void
- {
- $service_url = 'http://host1.domain:1234/path1';
-
- $this->authenticate();
- $resp = $this->server->get(
- self::$LINK_URL,
- [
- 'service' => $service_url,
- 'method' => 'POST',
- ],
- [
- CURLOPT_COOKIEJAR => $this->cookies_file,
- CURLOPT_COOKIEFILE => $this->cookies_file,
- ],
- );
-
- // POST responds with a form that is uses JavaScript to submit
- $this->assertEquals(200, $resp['code']);
-
- // Validate the form contains the required elements
- $body = $resp['body'];
- $dom = new DOMDocument();
- $dom->loadHTML($body);
- $form = $dom->getElementsByTagName('form');
- $item = $form->item(0);
- if (is_null($item)) {
- $this->fail('Unable to parse response.');
- return;
- }
-
- $this->assertEquals($service_url, $item->getAttribute('action'));
- $formInputs = $dom->getElementsByTagName('input');
- //note: $formInputs[0] is ''. See the post.php template from SSP
- $item = $formInputs->item(1);
- if (is_null($item)) {
- $this->fail('Unable to parse response.');
- return;
- }
- $this->assertEquals(
- 'ticket',
- $item->getAttribute('name'),
- );
- $this->assertStringStartsWith(
- 'ST-',
- $item->getAttribute('value'),
- '',
- );
- }
-
-
- /**
- */
- public function testSamlValidate(): void
- {
- $service_url = 'http://host1.domain:1234/path1';
- $this->authenticate();
-
- $resp = $this->server->get(
- self::$LINK_URL,
- ['service' => $service_url],
- [
- CURLOPT_COOKIEJAR => $this->cookies_file,
- CURLOPT_COOKIEFILE => $this->cookies_file,
- ],
- );
- $this->assertEquals(303, $resp['code']);
-
- $this->assertStringStartsWith(
- $service_url . '?ticket=ST-',
- $resp['headers']['Location'],
- 'Ticket should be part of the redirect.',
- );
-
- $location = $resp['headers']['Location'];
- $matches = [];
- $this->assertEquals(1, preg_match('@ticket=(.*)@', $location, $matches));
- $ticket = $matches[1];
- $soapRequest = <<
-
-
-
- $ticket
-
-
-
-SOAP;
-
- $resp = $this->post(
- self::$SAMLVALIDATE_URL,
- $soapRequest,
- [
- 'TARGET' => $service_url,
- ],
- );
-
- $this->assertEquals(200, $resp['code']);
- $this->assertStringContainsString('testuser@example.com', $resp['body']);
- }
-
-
- /**
- * Sets up an authenticated session for the cookie $jar
- */
- private function authenticate(): void
- {
- // Use cookies Jar to store auth session cookies
- $resp = $this->server->get(
- self::$LINK_URL,
- [],
- [
- CURLOPT_COOKIEJAR => $this->cookies_file,
- CURLOPT_COOKIEFILE => $this->cookies_file,
- CURLOPT_FOLLOWLOCATION => true,
- ],
- );
- $this->assertEquals(200, $resp['code'], $resp['body']);
- }
-
-
- /**
- * TODO: migrate into BuiltInServer
- * @param \CurlHandle $ch
- * @return array
- */
- private function execAndHandleCurlResponse(CurlHandle $ch): array
- {
- $resp = curl_exec($ch);
- if ($resp === false) {
- throw new \Exception('curl error: ' . curl_error($ch));
- }
-
- $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
- list($header, $body) = explode("\r\n\r\n", $resp, 2);
- $raw_headers = explode("\r\n", $header);
- array_shift($raw_headers);
- $headers = [];
- foreach ($raw_headers as $header) {
- list($name, $value) = explode(':', $header, 2);
- $headers[trim($name)] = trim($value);
- }
- curl_close($ch);
- return [
- 'code' => $code,
- 'headers' => $headers,
- 'body' => $body,
- ];
- }
-
-
- /**
- * TODO: migrate into BuiltInServer
- * @param string $query The path at the embedded server to query
- * @param string|array $body The content to post
- * @param array $parameters Any query parameters to add.
- * @param array $curlopts Additional curl options
- * @return array The response code, headers and body
- */
- public function post($query, $body, $parameters = [], $curlopts = []): array
- {
- $ch = curl_init();
- $url = 'http://' . $this->server_addr . $query;
- $url .= (!empty($parameters)) ? '?' . http_build_query($parameters) : '';
- curl_setopt_array($ch, [
- CURLOPT_URL => $url,
- CURLOPT_RETURNTRANSFER => 1,
- CURLOPT_HEADER => 1,
- CURLOPT_POST => 1,
- ]);
-
- // body may be multi dimensional, so we convert it ourselves
- // https://stackoverflow.com/a/21111209/54396
- $postParam = is_string($body) ? $body : http_build_query($body);
- curl_setopt($ch, CURLOPT_POSTFIELDS, $postParam);
- curl_setopt_array($ch, $curlopts);
- return $this->execAndHandleCurlResponse($ch);
- }
-}
diff --git a/tests/resources/saml/ST-892424614ae738153cf6fda6ea372f54489870b8eb b/tests/resources/saml/ST-892424614ae738153cf6fda6ea372f54489870b8eb
new file mode 100644
index 00000000..f04b8568
--- /dev/null
+++ b/tests/resources/saml/ST-892424614ae738153cf6fda6ea372f54489870b8eb
@@ -0,0 +1 @@
+a:8:{s:2:"id";s:45:"ST-892424614ae738153cf6fda6ea372f54489870b8eb";s:11:"validBefore";i:9939632015;s:7:"service";s:233:"https://comanage-ioi-dev.workbench.incommon.org/ssp/module.php/cas/linkback.php?stateId=_bd6b7a3d207ed26ea893f49e555515b5f839547b59%3Ahttps%3A%2F%2Fcomanage-ioi-dev.workbench.incommon.org%2Fssp%2Fmodule.php%2Fadmin%2Ftest%2Fcasserver";s:10:"forceAuthn";b:0;s:8:"userName";s:32:"107159103605108465131@google.com";s:10:"attributes";a:1:{s:22:"eduPersonPrincipalName";a:1:{i:0;s:32:"107159103605108465131@google.com";}}s:7:"proxies";a:0:{}s:9:"sessionId";s:26:"23n62fa26kck94olh1cpcftsq5";}
\ No newline at end of file
diff --git a/tests/resources/saml/samlRequest.xml b/tests/resources/saml/samlRequest.xml
new file mode 100644
index 00000000..6de610ab
--- /dev/null
+++ b/tests/resources/saml/samlRequest.xml
@@ -0,0 +1,8 @@
+
+
+
+
+ ST-892424614ae738153cf6fda6ea372f54489870b8eb
+
+
+
diff --git a/tests/src/Controller/Cas10ControllerTest.php b/tests/src/Controller/Cas10ControllerTest.php
new file mode 100644
index 00000000..0b37f18a
--- /dev/null
+++ b/tests/src/Controller/Cas10ControllerTest.php
@@ -0,0 +1,335 @@
+sspConfig = Configuration::getConfig('config.php');
+ $this->sessionId = session_create_id();
+ $this->moduleConfig = [
+ 'ticketstore' => [
+ 'class' => 'casserver:FileSystemTicketStore', //Not intended for production
+ 'directory' => __DIR__ . '../../../../tests/ticketcache',
+ ],
+ ];
+
+ $this->sessionMock = $this->getMockBuilder(Session::class)
+ ->disableOriginalConstructor()
+ ->onlyMethods(['getSessionId'])
+ ->getMock();
+
+ $this->ticket = [
+ 'id' => 'ST-' . $this->sessionId,
+ 'validBefore' => 1731111111,
+ // phpcs:ignore Generic.Files.LineLength.TooLong
+ 'service' => 'https://myservice.com/abcd',
+ 'forceAuthn' => false,
+ 'userName' => 'username@google.com',
+ 'attributes' =>
+ [
+ 'eduPersonPrincipalName' =>
+ [
+ 0 => 'eduPersonPrincipalName@google.com',
+ ],
+ ],
+ 'proxies' =>
+ [
+ ],
+ 'sessionId' => $this->sessionId,
+ ];
+ }
+
+ public static function setUpBeforeClass(): void
+ {
+ // Some of the constructs in this test cause a Configuration to be created prior to us
+ // setting the one we want to use for the test.
+ Configuration::clearInternalState();
+
+ // To make lib/SimpleSAML/Utils/HTTP::getSelfURL() work...
+ global $_SERVER;
+ $_SERVER['REQUEST_URI'] = '/';
+ }
+
+ public static function queryParameterValues(): array
+ {
+ return [
+ 'Has Service' => [
+ ['service' => 'https://myservice.com/abcd'],
+ ],
+ 'Has Ticket' => [
+ ['ticket' => '1234567'],
+ ],
+ 'Has Neither Service Nor Ticket' => [
+ [],
+ ],
+ ];
+ }
+
+ /**
+ * @param array $params
+ *
+ * @return void
+ * @throws Exception
+ */
+ #[DataProvider('queryParameterValues')]
+ public function testReturnBadRequestOnEmptyServiceOrTicket(array $params): void
+ {
+ $config = Configuration::loadFromArray($this->moduleConfig);
+
+ $request = Request::create(
+ uri: 'http://localhost',
+ parameters: $params,
+ );
+
+ $cas10Controller = new Cas10Controller($this->sspConfig, $config);
+ $response = $cas10Controller->validate($request, ...$params);
+
+ $this->assertEquals(400, $response->getStatusCode());
+ $this->assertEquals("no\n\n", $response->getContent());
+ }
+
+ /**
+ * @return void
+ * @throws Exception
+ */
+ public function testReturn500OnDeleteTicketThatThrows(): void
+ {
+ $config = Configuration::loadFromArray($this->moduleConfig);
+ $params = [
+ 'ticket' => $this->sessionId,
+ 'service' => 'https://myservice.com/abcd',
+ ];
+
+ $request = Request::create(
+ uri: 'http://localhost',
+ parameters: $params,
+ );
+
+ $ticketStore = new class ($config) extends FileSystemTicketStore {
+ public function getTicket(string $ticketId): ?array
+ {
+ throw new Exception();
+ }
+ };
+
+ /** @psalm-suppress InvalidArgument */
+ $cas10Controller = new Cas10Controller($this->sspConfig, $config, $ticketStore);
+ $response = $cas10Controller->validate($request, ...$params);
+
+ $this->assertEquals(500, $response->getStatusCode());
+ $this->assertEquals("no\n\n", $response->getContent());
+ }
+
+ /**
+ * @return void
+ * @throws Exception
+ */
+ public function testReturnBadRequestOnTicketNotExist(): void
+ {
+ $config = Configuration::loadFromArray($this->moduleConfig);
+ $params = [
+ 'ticket' => $this->sessionId,
+ 'service' => 'https://myservice.com/abcd',
+ ];
+
+ $request = Request::create(
+ uri: 'http://localhost',
+ parameters: $params,
+ );
+
+ $cas10Controller = new Cas10Controller($this->sspConfig, $config);
+ $response = $cas10Controller->validate($request, ...$params);
+
+ $this->assertEquals(400, $response->getStatusCode());
+ $this->assertEquals("no\n\n", $response->getContent());
+ }
+
+ /**
+ * @return void
+ * @throws Exception
+ */
+ public function testReturnBadRequestOnTicketExpired(): void
+ {
+ $config = Configuration::loadFromArray($this->moduleConfig);
+ $params = [
+ 'ticket' => 'ST-' . $this->sessionId,
+ 'service' => 'https://myservice.com/abcd',
+ ];
+
+ $request = Request::create(
+ uri: 'http://localhost',
+ parameters: $params,
+ );
+
+ $cas10Controller = new Cas10Controller($this->sspConfig, $config);
+ $ticketStore = $cas10Controller->getTicketStore();
+ $ticketStore->addTicket($this->ticket);
+ $response = $cas10Controller->validate($request, ...$params);
+
+ $this->assertEquals(400, $response->getStatusCode());
+ $this->assertEquals("no\n\n", $response->getContent());
+ }
+
+ /**
+ * @return void
+ * @throws Exception
+ */
+ public function testReturnBadRequestOnTicketNotService(): void
+ {
+ $config = Configuration::loadFromArray($this->moduleConfig);
+ $params = [
+ 'ticket' => $this->sessionId,
+ 'service' => 'https://myservice.com/abcd',
+ ];
+
+ $this->ticket['id'] = $this->sessionId;
+
+ $request = Request::create(
+ uri: 'http://localhost',
+ parameters: $params,
+ );
+
+ $cas10Controller = new Cas10Controller($this->sspConfig, $config);
+ $ticketStore = $cas10Controller->getTicketStore();
+ $ticketStore->addTicket($this->ticket);
+ $response = $cas10Controller->validate($request, ...$params);
+
+ $this->assertEquals(400, $response->getStatusCode());
+ $this->assertEquals("no\n\n", $response->getContent());
+ }
+
+ /**
+ * @return void
+ * @throws Exception
+ */
+ public function testReturnBadRequestOnTicketMissingUsernameField(): void
+ {
+ $config = Configuration::loadFromArray($this->moduleConfig);
+ $params = [
+ 'ticket' => 'ST-' . $this->sessionId,
+ 'service' => 'https://myservice.com/abcd',
+ ];
+ $this->ticket['validBefore'] = 9999999999;
+ $this->ticket['userName'] = '';
+
+ $request = Request::create(
+ uri: 'http://localhost',
+ parameters: $params,
+ );
+
+ $cas10Controller = new Cas10Controller($this->sspConfig, $config);
+ $ticketStore = $cas10Controller->getTicketStore();
+ $ticketStore->addTicket($this->ticket);
+ $response = $cas10Controller->validate($request, ...$params);
+
+ $this->assertEquals(400, $response->getStatusCode());
+ $this->assertEquals("no\n\n", $response->getContent());
+ }
+
+ /**
+ * @return void
+ * @throws Exception
+ */
+ public function testReturnBadRequestOnTicketServiceQueryAndTicketMismatch(): void
+ {
+ $config = Configuration::loadFromArray($this->moduleConfig);
+ $params = [
+ 'ticket' => 'ST-' . $this->sessionId,
+ 'service' => 'https://myservice.com/failservice',
+ ];
+ $this->ticket['validBefore'] = 9999999999;
+ $this->ticket['attributes'] = [];
+
+ $request = Request::create(
+ uri: 'http://localhost',
+ parameters: $params,
+ );
+
+ $cas10Controller = new Cas10Controller($this->sspConfig, $config);
+ $ticketStore = $cas10Controller->getTicketStore();
+ $ticketStore->addTicket($this->ticket);
+ $response = $cas10Controller->validate($request, ...$params);
+
+ $this->assertEquals(400, $response->getStatusCode());
+ $this->assertEquals("no\n\n", $response->getContent());
+ }
+
+ /**
+ * @return void
+ * @throws Exception
+ */
+ public function testReturnBadRequestOnTicketIssuedBySingleSignOnSession(): void
+ {
+ $config = Configuration::loadFromArray($this->moduleConfig);
+ $params = [
+ 'ticket' => 'ST-' . $this->sessionId,
+ 'service' => 'https://myservice.com/abcd',
+ 'renew' => true,
+ ];
+ $this->ticket['validBefore'] = 9999999999;
+
+ $request = Request::create(
+ uri: 'http://localhost',
+ parameters: $params,
+ );
+
+ $cas10Controller = new Cas10Controller($this->sspConfig, $config);
+ $ticketStore = $cas10Controller->getTicketStore();
+ $ticketStore->addTicket($this->ticket);
+ $response = $cas10Controller->validate($request, ...$params);
+
+ $this->assertEquals(400, $response->getStatusCode());
+ $this->assertEquals("no\n\n", $response->getContent());
+ }
+
+ /**
+ * @return void
+ * @throws Exception
+ */
+ public function testSuccessfullValidation(): void
+ {
+ $config = Configuration::loadFromArray($this->moduleConfig);
+ $params = [
+ 'ticket' => 'ST-' . $this->sessionId,
+ 'service' => 'https://myservice.com/abcd',
+ ];
+
+ $this->ticket['validBefore'] = 9999999999;
+
+ $request = Request::create(
+ uri: 'http://localhost',
+ parameters: $params,
+ );
+
+ $cas10Controller = new Cas10Controller($this->sspConfig, $config);
+ $ticketStore = $cas10Controller->getTicketStore();
+ $ticketStore->addTicket($this->ticket);
+ $response = $cas10Controller->validate($request, ...$params);
+
+ $this->assertEquals(200, $response->getStatusCode());
+ $this->assertEquals("yes\nusername@google.com\n", $response->getContent());
+ }
+}
diff --git a/tests/src/Controller/Cas20ControllerTest.php b/tests/src/Controller/Cas20ControllerTest.php
new file mode 100644
index 00000000..679c275c
--- /dev/null
+++ b/tests/src/Controller/Cas20ControllerTest.php
@@ -0,0 +1,723 @@
+sspConfig = Configuration::getConfig('config.php');
+ $this->sessionId = session_create_id();
+ $this->moduleConfig = [
+ 'ticketstore' => [
+ 'class' => 'casserver:FileSystemTicketStore', //Not intended for production
+ 'directory' => __DIR__ . '../../../../tests/ticketcache',
+ ],
+ ];
+
+ // Hard code the ticket store
+ $this->ticketStore = new FileSystemTicketStore(Configuration::loadFromArray($this->moduleConfig));
+
+ $this->ticketValidatorMock = $this->getMockBuilder(TicketValidator::class)
+ ->setConstructorArgs([Configuration::loadFromArray($this->moduleConfig)])
+ ->onlyMethods(['validateAndDeleteTicket'])
+ ->getMock();
+
+ $this->sessionMock = $this->getMockBuilder(Session::class)
+ ->disableOriginalConstructor()
+ ->onlyMethods(['getSessionId'])
+ ->getMock();
+
+ $this->utilsHttpMock = $this->getMockBuilder(Utils\HTTP::class)
+ ->disableOriginalConstructor()
+ ->onlyMethods(['fetch'])
+ ->getMock();
+
+ $this->ticket = [
+ 'id' => 'ST-' . $this->sessionId,
+ 'validBefore' => 1731111111,
+ 'service' => 'https://myservice.com/abcd',
+ 'forceAuthn' => false,
+ 'userName' => 'username@google.com',
+ 'attributes' =>
+ [
+ 'eduPersonPrincipalName' =>
+ [
+ 0 => 'eduPersonPrincipalName@google.com',
+ ],
+ ],
+ 'proxies' =>
+ [
+ ],
+ 'sessionId' => $this->sessionId,
+ ];
+
+ $this->proxyTicket = [
+ 'id' => 'PT-' . $this->sessionId,
+ 'validBefore' => 1731111111,
+ 'service' => 'https://myservice.com/abcd',
+ 'forceAuthn' => false,
+ 'userName' => 'username@google.com',
+ 'attributes' =>
+ [
+ 'eduPersonPrincipalName' =>
+ [
+ 0 => 'eduPersonPrincipalName@google.com',
+ ],
+ ],
+ 'proxies' =>
+ [
+ ],
+ 'sessionId' => $this->sessionId,
+ ];
+ }
+
+ public static function validateMethods(): array
+ {
+ return [
+ 'Call serviceValidate action' => [
+ 'ST-',
+ 'serviceValidate',
+ ],
+ 'Call proxyValidate action' => [
+ 'PT-',
+ 'proxyValidate',
+ ],
+ ];
+ }
+
+ #[DataProvider('validateMethods')]
+ public function testProxyValidatePassesTheCorrectMethodToValidate(string $prefix, string $method): void
+ {
+ $casconfig = Configuration::loadFromArray($this->moduleConfig);
+ $requestParameters = [
+ 'renew' => false,
+ 'service' => 'https://myservice.com/abcd',
+ 'ticket' => $prefix . $this->sessionId,
+ ];
+
+ $request = Request::create(
+ uri: 'https://myservice.com/abcd',
+ parameters: $requestParameters,
+ );
+
+ $expectedArguments = [
+ 'request' => $request,
+ 'method' => $method,
+ 'renew' => false,
+ 'target' => null,
+ 'ticket' => $prefix . $this->sessionId,
+ 'service' => 'https://myservice.com/abcd',
+ 'pgtUrl' => null,
+ ];
+
+ $controllerMock = $this->getMockBuilder(Cas20Controller::class)
+ ->setConstructorArgs([$this->sspConfig, $casconfig])
+ ->onlyMethods(['validate'])
+ ->getMock();
+
+ $controllerMock->expects($this->once())->method('validate')
+ ->with(...$expectedArguments);
+ $controllerMock->$method($request, ...$requestParameters);
+ }
+
+ public static function queryParameterValues(): array
+ {
+ return [
+ 'Only targetService query parameter' => [
+ ['targetService' => 'https://myservice.com/abcd', 'pgt' => null],
+ 'Missing proxy granting ticket parameter: [pgt]',
+ ],
+ 'Only pgt query parameter' => [
+ ['pgt' => '1234567', 'targetService' => null],
+ 'Missing target service parameter [targetService]',
+ ],
+ 'Has Neither pgt Nor targetService query parameters' => [
+ ['pgt' => null, 'targetService' => null],
+ 'Missing target service parameter [targetService]',
+ ],
+ 'Target service query parameter not listed' => [
+ ['pgt' => 'pgt', 'targetService' => 'https://myservice.com/abcd'],
+ 'Target service parameter not listed as a legal service: [targetService] = https://myservice.com/abcd',
+ ],
+ ];
+ }
+
+ #[DataProvider('queryParameterValues')]
+ public function testProxyRequestFails(array $params, string $message): void
+ {
+ $casconfig = Configuration::loadFromArray($this->moduleConfig);
+
+ $this->samlValidateRequest = Request::create(
+ uri: Module::getModuleURL('casserver/proxy'),
+ parameters: $params,
+ );
+
+ $cas20Controller = new Cas20Controller(
+ $this->sspConfig,
+ $casconfig,
+ );
+
+ $response = $cas20Controller->proxy($this->samlValidateRequest, ...$params);
+ $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
+ $this->assertStringContainsString($message, $response->getContent());
+ $xml = simplexml_load_string($response->getContent());
+ $xml->registerXPathNamespace('cas', 'serviceResponse');
+ $this->assertEquals('serviceResponse', $xml->getName());
+ $this->assertEquals(
+ C::ERR_INVALID_REQUEST,
+ $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'],
+ );
+ }
+
+ public function testProxyRequestFailsWhenPgtNotRecognized(): void
+ {
+ $this->moduleConfig['legal_target_service_urls'] = ['https://myservice.com/abcd'];
+ $casconfig = Configuration::loadFromArray($this->moduleConfig);
+
+ $params = ['pgt' => 'pgt', 'targetService' => 'https://myservice.com/abcd'];
+ $this->samlValidateRequest = Request::create(
+ uri: Module::getModuleURL('casserver/proxy'),
+ parameters: $params,
+ );
+
+ $cas20Controller = new Cas20Controller(
+ $this->sspConfig,
+ $casconfig,
+ );
+
+ $response = $cas20Controller->proxy($this->samlValidateRequest, ...$params);
+ $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
+
+ $xml = simplexml_load_string($response->getContent());
+ $xml->registerXPathNamespace('cas', 'serviceResponse');
+ $this->assertEquals('serviceResponse', $xml->getName());
+ $this->assertEquals('BAD_PGT', $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code']);
+ $this->assertEquals(
+ 'Ticket pgt not recognized',
+ $xml->xpath('//cas:authenticationFailure')[0],
+ );
+ }
+
+ public function testProxyRequestFailsWhenPgtNotValid(): void
+ {
+ $this->moduleConfig['legal_target_service_urls'] = ['https://myservice.com/abcd'];
+ $casconfig = Configuration::loadFromArray($this->moduleConfig);
+
+ $params = ['pgt' => $this->ticket['id'], 'targetService' => 'https://myservice.com/abcd'];
+ $this->samlValidateRequest = Request::create(
+ uri: Module::getModuleURL('casserver/proxy'),
+ parameters: $params,
+ );
+
+ $this->ticketStore->addTicket(['id' => $this->ticket['id']]);
+
+ $cas20Controller = new Cas20Controller(
+ $this->sspConfig,
+ $casconfig,
+ );
+
+ $response = $cas20Controller->proxy($this->samlValidateRequest, ...$params);
+ $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
+
+ $xml = simplexml_load_string($response->getContent());
+ $xml->registerXPathNamespace('cas', 'serviceResponse');
+ $this->assertEquals('serviceResponse', $xml->getName());
+ $this->assertEquals('BAD_PGT', $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code']);
+ $this->assertEquals(
+ 'Not a valid proxy granting ticket id: ' . $this->ticket['id'],
+ $xml->xpath('//cas:authenticationFailure')[0],
+ );
+ }
+
+ public function testProxyRequestFailsWhenPgtExpired(): void
+ {
+ $this->moduleConfig['legal_target_service_urls'] = ['https://myservice.com/abcd'];
+ $casconfig = Configuration::loadFromArray($this->moduleConfig);
+ $ticket = [
+ 'id' => 'PGT-' . $this->sessionId,
+ 'validBefore' => 9999999999,
+ 'service' => 'https://myservice.com/abcd',
+ 'sessionId' => $this->sessionId,
+ ];
+ $params = ['pgt' => $ticket['id'], 'targetService' => 'https://myservice.com/abcd'];
+ $this->samlValidateRequest = Request::create(
+ uri: Module::getModuleURL('casserver/proxy'),
+ parameters: $params,
+ );
+
+ $this->ticketStore->addTicket($ticket);
+
+ $cas20Controller = new Cas20Controller(
+ $this->sspConfig,
+ $casconfig,
+ );
+
+ $response = $cas20Controller->proxy($this->samlValidateRequest, ...$params);
+ $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
+
+ $xml = simplexml_load_string($response->getContent());
+ $xml->registerXPathNamespace('cas', 'serviceResponse');
+ $this->assertEquals('serviceResponse', $xml->getName());
+ $this->assertEquals('BAD_PGT', $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code']);
+ $this->assertEquals(
+ "Ticket {$ticket['id']} has expired",
+ $xml->xpath('//cas:authenticationFailure')[0],
+ );
+ }
+
+ public function testProxyReturnsProxyTicket(): void
+ {
+ $this->moduleConfig['legal_target_service_urls'] = ['https://myservice.com/abcd'];
+ $casconfig = Configuration::loadFromArray($this->moduleConfig);
+ $ticket = [
+ 'id' => 'PGT-' . $this->sessionId,
+ 'validBefore' => 9999999999,
+ 'service' => 'https://myservice.com/abcd',
+ 'sessionId' => $this->sessionId,
+ 'forceAuthn' => false,
+ 'attributes' =>
+ [
+ 'eduPersonPrincipalName' =>
+ [
+ 0 => 'eduPersonPrincipalName@google.com',
+ ],
+ ],
+ 'proxies' =>
+ [
+ ],
+ ];
+ $sessionTicket = [
+ 'id' => $this->sessionId,
+ 'validBefore' => 9999999999,
+ 'service' => 'https://myservice.com/abcd',
+ 'sessionId' => $this->sessionId,
+ ];
+ $params = ['pgt' => $ticket['id'], 'targetService' => 'https://myservice.com/abcd'];
+ $this->samlValidateRequest = Request::create(
+ uri: Module::getModuleURL('casserver/proxy'),
+ parameters: $params,
+ );
+
+ $this->ticketStore->addTicket($ticket);
+ $this->ticketStore->addTicket($sessionTicket);
+ $ticketFactory = new TicketFactory($casconfig);
+
+ $cas20Controller = new Cas20Controller(
+ $this->sspConfig,
+ $casconfig,
+ );
+
+ $response = $cas20Controller->proxy($this->samlValidateRequest, ...$params);
+ $this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
+
+ $xml = simplexml_load_string($response->getContent());
+ $xml->registerXPathNamespace('cas', 'serviceResponse');
+ $this->assertEquals('serviceResponse', $xml->getName());
+ $this->assertNotNull($xml->xpath('//cas:proxySuccess'));
+ $ticketId = (string)$xml->xpath('//cas:proxyTicket')[0];
+ $proxyTicket = $this->ticketStore->getTicket($ticketId);
+ $this->assertTrue(filter_var($ticketFactory->isProxyTicket($proxyTicket), FILTER_VALIDATE_BOOLEAN));
+ }
+
+ public static function validateFailsForEmptyServiceTicket(): array
+ {
+ return [
+ 'Service URL & TARGET is empty' => [
+ ['ticket' => 'ST-12334Q45W4563'],
+ 'casserver: Missing service parameter: [service]',
+ ],
+ 'Ticket is empty but Service is not' => [
+ ['service' => 'http://localhost'],
+ 'casserver: Missing ticket parameter: [ticket]',
+ ],
+ 'Ticket is empty but TARGET is not' => [
+ ['TARGET' => 'http://localhost'],
+ 'casserver: Missing ticket parameter: [ticket]',
+ ],
+ ];
+ }
+
+ #[DataProvider('validateFailsForEmptyServiceTicket')]
+ public function testServiceValidateFailing(array $requestParams, string $message): void
+ {
+ $casconfig = Configuration::loadFromArray($this->moduleConfig);
+ $request = Request::create(
+ uri: '/',
+ parameters: $requestParams,
+ );
+ $cas20Controller = new Cas20Controller(
+ $this->sspConfig,
+ $casconfig,
+ );
+
+ $response = $cas20Controller->serviceValidate($request, ...$requestParams);
+ $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
+ $this->assertStringContainsString($message, $response->getContent());
+ $xml = simplexml_load_string($response->getContent());
+ $xml->registerXPathNamespace('cas', 'serviceResponse');
+ $this->assertEquals('serviceResponse', $xml->getName());
+ $this->assertEquals(
+ C::ERR_INVALID_SERVICE,
+ $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'],
+ );
+ }
+
+ public function testReturn500OnDeleteTicketThatThrows(): void
+ {
+ $config = Configuration::loadFromArray($this->moduleConfig);
+ $params = [
+ 'ticket' => $this->sessionId,
+ 'service' => 'https://myservice.com/abcd',
+ ];
+
+ $request = Request::create(
+ uri: 'http://localhost',
+ parameters: $params,
+ );
+
+ $ticketStore = new class ($config) extends FileSystemTicketStore {
+ public function getTicket(string $ticketId): ?array
+ {
+ throw new \Exception();
+ }
+ };
+
+ /** @psalm-suppress InvalidArgument */
+ $cas20Controller = new Cas20Controller($this->sspConfig, $config, $ticketStore);
+ $response = $cas20Controller->serviceValidate($request, ...$params);
+
+ $message = 'casserver:serviceValidate: internal server error';
+ $this->assertEquals(Response::HTTP_INTERNAL_SERVER_ERROR, $response->getStatusCode());
+ $this->assertStringContainsString($message, $response->getContent());
+ $xml = simplexml_load_string($response->getContent());
+ $xml->registerXPathNamespace('cas', 'serviceResponse');
+ $this->assertEquals('serviceResponse', $xml->getName());
+ $this->assertEquals(
+ C::ERR_INTERNAL_ERROR,
+ $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'],
+ );
+ }
+
+ public static function validateOnDifferentQueryParameterCombinations(): array
+ {
+ $sessionId = session_create_id();
+ return [
+ 'Returns Bad Request on Ticket Not Recognised/Exists' => [
+ [
+ 'ticket' => $sessionId,
+ 'service' => 'https://myservice.com/abcd',
+ ],
+ "Ticket '{$sessionId}' not recognized",
+ 'ST-' . $sessionId,
+ ],
+ 'Returns Bad Request on Ticket is Proxy' => [
+ [
+ 'ticket' => 'PT-' . $sessionId,
+ 'service' => 'https://myservice.com/abcd',
+ ],
+ "Ticket 'PT-{$sessionId}' is not a service ticket.",
+ 'PT-' . $sessionId,
+ ],
+ 'Returns Bad Request on Ticket Expired' => [
+ [
+ 'ticket' => 'ST-' . $sessionId,
+ 'service' => 'https://myservice.com/abcd',
+ ],
+ "Ticket 'ST-{$sessionId}' has expired",
+ 'ST-' . $sessionId,
+ ],
+ 'Returns Bad Request on Ticket Not A Service Ticket' => [
+ [
+ 'ticket' => $sessionId,
+ 'service' => 'https://myservice.com/abcd',
+ ],
+ "Ticket '{$sessionId}' is not a service ticket",
+ $sessionId,
+ ],
+ 'Returns Bad Request on Ticket Issued By Single SignOn Session' => [
+ [
+ 'ticket' => 'ST-' . $sessionId,
+ 'service' => 'https://myservice.com/abcd',
+ 'renew' => true,
+ ],
+ 'Ticket was issued from single sign on session',
+ 'ST-' . $sessionId,
+ 9999999999,
+ ],
+ 'Returns Success' => [
+ [
+ 'ticket' => 'ST-' . $sessionId,
+ 'service' => 'https://myservice.com/abcd',
+ ],
+ 'username@google.com',
+ 'ST-' . $sessionId,
+ 9999999999,
+ ],
+ ];
+ }
+
+ #[DataProvider('validateOnDifferentQueryParameterCombinations')]
+ public function testServiceValidate(
+ array $requestParams,
+ string $message,
+ string $ticketId,
+ int $validBefore = 1111111111,
+ ): void {
+ $config = Configuration::loadFromArray($this->moduleConfig);
+
+ $request = Request::create(
+ uri: 'http://localhost',
+ parameters: $requestParams,
+ );
+
+ $cas20Controller = new Cas20Controller($this->sspConfig, $config);
+
+ if (!empty($ticketId)) {
+ $ticketStore = $cas20Controller->getTicketStore();
+ $ticket = $this->ticket;
+ $ticket['id'] = $ticketId;
+ $ticket['validBefore'] = $validBefore;
+ $ticketStore->addTicket($ticket);
+ }
+
+ $response = $cas20Controller->serviceValidate($request, ...$requestParams);
+
+ if ($message === 'username@google.com') {
+ $this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
+ $this->assertStringContainsString($message, $response->getContent());
+ $xml = simplexml_load_string($response->getContent());
+ $xml->registerXPathNamespace('cas', 'serviceResponse');
+ $this->assertEquals('serviceResponse', $xml->getName());
+ $this->assertEquals(
+ 'username@google.com',
+ $xml->xpath('//cas:authenticationSuccess/cas:user')[0][0],
+ );
+ } else {
+ $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
+ $this->assertStringContainsString($message, $response->getContent());
+ $xml = simplexml_load_string($response->getContent());
+ $xml->registerXPathNamespace('cas', 'serviceResponse');
+ $this->assertEquals('serviceResponse', $xml->getName());
+ $this->assertEquals(
+ C::ERR_INVALID_SERVICE,
+ $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'],
+ );
+ }
+ }
+
+
+ public static function validateOnDifferentQueryParameterCombinationsProxyValidate(): array
+ {
+ $sessionId = session_create_id();
+ return [
+ 'Returns Bad Request on Ticket Not Recognised/Exists' => [
+ [
+ 'ticket' => $sessionId,
+ 'service' => 'https://myservice.com/abcd',
+ ],
+ "Ticket '{$sessionId}' not recognized",
+ 'PT-' . $sessionId,
+ ],
+ 'Returns Bad Request on Ticket Expired' => [
+ [
+ 'ticket' => 'PT-' . $sessionId,
+ 'service' => 'https://myservice.com/abcd',
+ ],
+ "Ticket 'PT-{$sessionId}' has expired",
+ 'PT-' . $sessionId,
+ ],
+ 'Returns Bad Request on Ticket is A Service Ticket' => [
+ [
+ 'ticket' => 'ST-' . $sessionId,
+ 'service' => 'https://myservice.com/abcd',
+ ],
+ "Ticket 'ST-{$sessionId}' is not a proxy ticket.",
+ 'ST-' . $sessionId,
+ ],
+ 'Returns Bad Request on Ticket Issued By Single SignOn Session' => [
+ [
+ 'ticket' => 'PT-' . $sessionId,
+ 'service' => 'https://myservice.com/abcd',
+ 'renew' => true,
+ ],
+ 'Ticket was issued from single sign on session',
+ 'PT-' . $sessionId,
+ 9999999999,
+ ],
+ 'Returns Success' => [
+ [
+ 'ticket' => 'PT-' . $sessionId,
+ 'service' => 'https://myservice.com/abcd',
+ ],
+ 'username@google.com',
+ 'PT-' . $sessionId,
+ 9999999999,
+ ],
+ ];
+ }
+
+ #[DataProvider('validateOnDifferentQueryParameterCombinationsProxyValidate')]
+ public function testProxyValidate(
+ array $requestParams,
+ string $message,
+ string $ticketId,
+ int $validBefore = 1111111111,
+ ): void {
+ $config = Configuration::loadFromArray($this->moduleConfig);
+
+ $request = Request::create(
+ uri: 'http://localhost',
+ parameters: $requestParams,
+ );
+
+ $cas20Controller = new Cas20Controller($this->sspConfig, $config);
+
+ if (!empty($ticketId)) {
+ $ticketStore = $cas20Controller->getTicketStore();
+ $ticket = $this->proxyTicket;
+ $ticket['id'] = $ticketId;
+ $ticket['validBefore'] = $validBefore;
+ $ticketStore->addTicket($ticket);
+ }
+
+ $response = $cas20Controller->proxyValidate($request, ...$requestParams);
+
+ if ($message === 'username@google.com') {
+ $this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
+ $this->assertStringContainsString($message, $response->getContent());
+ $xml = simplexml_load_string($response->getContent());
+ $xml->registerXPathNamespace('cas', 'serviceResponse');
+ $this->assertEquals('serviceResponse', $xml->getName());
+ $this->assertEquals(
+ 'username@google.com',
+ $xml->xpath('//cas:authenticationSuccess/cas:user')[0][0],
+ );
+ } else {
+ $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
+ $this->assertStringContainsString($message, $response->getContent());
+ $xml = simplexml_load_string($response->getContent());
+ $xml->registerXPathNamespace('cas', 'serviceResponse');
+ $this->assertEquals('serviceResponse', $xml->getName());
+ $this->assertEquals(
+ C::ERR_INVALID_SERVICE,
+ $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'],
+ );
+ }
+ }
+
+ public function testReturnBadRequestOnTicketServiceQueryAndTicketMismatch(): void
+ {
+ $config = Configuration::loadFromArray($this->moduleConfig);
+ $params = [
+ 'ticket' => 'ST-' . $this->sessionId,
+ 'service' => 'https://myservice.com/failservice',
+ ];
+ $this->ticket['validBefore'] = 9999999999;
+ $this->ticket['attributes'] = [];
+
+ $request = Request::create(
+ uri: 'http://localhost',
+ parameters: $params,
+ );
+
+ $cas20Controller = new Cas20Controller($this->sspConfig, $config);
+ $ticketStore = $cas20Controller->getTicketStore();
+ $ticketStore->addTicket($this->ticket);
+ $response = $cas20Controller->serviceValidate($request, ...$params);
+
+ $message = "Mismatching service parameters: expected 'https://myservice.com/abcd'" .
+ " but was: 'https://myservice.com/failservice'";
+ $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
+ $this->assertStringContainsString($message, $response->getContent());
+ $xml = simplexml_load_string($response->getContent());
+ $xml->registerXPathNamespace('cas', 'serviceResponse');
+ $this->assertEquals('serviceResponse', $xml->getName());
+ $this->assertEquals(
+ C::ERR_INVALID_SERVICE,
+ $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'],
+ );
+ }
+
+ public function testThrowOnProxyServiceIdentityFail(): void
+ {
+ $config = Configuration::loadFromArray($this->moduleConfig);
+ $params = [
+ 'ticket' => 'ST-' . $this->sessionId,
+ 'service' => 'https://myservice.com/abcd',
+ 'pgtUrl' => 'https://myservice.com/proxy',
+ ];
+ $this->ticket['validBefore'] = 9999999999;
+ $sessionTicket = $this->ticket;
+ $sessionTicket['id'] = $this->sessionId;
+
+ $request = Request::create(
+ uri: 'http://localhost',
+ parameters: $params,
+ );
+
+ $this->utilsHttpMock->expects($this->once())
+ ->method('fetch')
+ ->willThrowException(new \Exception());
+
+ $cas20Controller = new Cas20Controller(
+ sspConfig: $this->sspConfig,
+ casConfig: $config,
+ httpUtils: $this->utilsHttpMock,
+ );
+ $ticketStore = $cas20Controller->getTicketStore();
+ $ticketStore->addTicket($this->ticket);
+ $ticketStore->addTicket($sessionTicket);
+ $response = $cas20Controller->serviceValidate($request, ...$params);
+
+ $message = 'Proxy callback url is failing.';
+ $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
+ $this->assertStringContainsString($message, $response->getContent());
+ $xml = simplexml_load_string($response->getContent());
+ $xml->registerXPathNamespace('cas', 'serviceResponse');
+ $this->assertEquals('serviceResponse', $xml->getName());
+ $this->assertEquals(
+ C::ERR_INVALID_SERVICE,
+ $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'],
+ );
+ }
+}
diff --git a/tests/src/Controller/Cas30ControllerTest.php b/tests/src/Controller/Cas30ControllerTest.php
new file mode 100644
index 00000000..d1518aaf
--- /dev/null
+++ b/tests/src/Controller/Cas30ControllerTest.php
@@ -0,0 +1,351 @@
+sspConfig = Configuration::getConfig('config.php');
+ $this->sessionId = session_create_id();
+ $this->moduleConfig = [
+ 'ticketstore' => [
+ 'class' => 'casserver:FileSystemTicketStore', //Not intended for production
+ 'directory' => __DIR__ . '../../../../tests/ticketcache',
+ ],
+ ];
+
+ // Hard code the ticket store
+ $this->ticketStore = new FileSystemTicketStore(Configuration::loadFromArray($this->moduleConfig));
+
+ $this->ticketValidatorMock = $this->getMockBuilder(TicketValidator::class)
+ ->setConstructorArgs([Configuration::loadFromArray($this->moduleConfig)])
+ ->onlyMethods(['validateAndDeleteTicket'])
+ ->getMock();
+
+ $this->sessionMock = $this->getMockBuilder(Session::class)
+ ->disableOriginalConstructor()
+ ->onlyMethods(['getSessionId'])
+ ->getMock();
+
+ $this->ticket = [
+ 'id' => 'ST-' . $this->sessionId,
+ 'validBefore' => 9999999999,
+ 'service' => 'https://myservice.com/abcd',
+ 'forceAuthn' => false,
+ 'userName' => 'username@google.com',
+ 'attributes' =>
+ [
+ 'eduPersonPrincipalName' =>
+ [
+ 0 => 'eduPersonPrincipalName@google.com',
+ ],
+ ],
+ 'proxies' =>
+ [
+ ],
+ 'sessionId' => $this->sessionId,
+ ];
+ }
+
+ /**
+ * @return void
+ * @throws \Exception
+ */
+ public function testNoSoapBody(): void
+ {
+ $casconfig = Configuration::loadFromArray($this->moduleConfig);
+
+ $target = 'https://comanage-ioi-dev.workbench.incommon.org/ssp/module.php/cas/linkback.php?'
+ . 'stateId=_bd6b7a3d207ed26ea893f49e555515b5f839547b59%3A'
+ . 'https%3A%2F%2Fcomanage-ioi-dev.workbench.incommon.org%2Fssp%2Fmodule.php%2Fadmin%2Ftest%2Fcasserver';
+ $this->samlValidateRequest = Request::create(
+ uri: Module::getModuleURL('casserver/samlValidate'),
+ method: 'POST',
+ parameters: ['TARGET' => $target],
+ content: '',
+ );
+
+ $cas30Controller = new Cas30Controller(
+ $this->sspConfig,
+ $casconfig,
+ );
+
+ // Exception expected
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('samlValidate expects a soap body.');
+
+ $cas30Controller->samlValidate($this->samlValidateRequest, $target);
+ }
+
+ public static function soapEnvelopes(): array
+ {
+ return [
+ 'Body Missing RequestID Attribute' => [
+ <<
+
+
+
+ ST-892424614ae738153cf6fda6ea372f54489870b8eb
+
+
+
+SOAP,
+ "Missing 'RequestID' attribute on samlp:Request.",
+ 'SimpleSAML\XML\Exception\MissingAttributeException',
+ ],
+ 'Body Missing IssueInstant Attribute' => [
+ <<
+
+
+
+ ST-892424614ae738153cf6fda6ea372f54489870b8eb
+
+
+
+SOAP,
+ "Missing 'IssueInstant' attribute on samlp:Request.",
+ 'SimpleSAML\XML\Exception\MissingAttributeException',
+ ],
+ 'Body Missing Ticket Id' => [
+ <<
+
+
+
+
+
+
+
+SOAP,
+ 'Expected a non-whitespace string. Got: ""',
+ 'SimpleSAML\SAML11\Exception\ProtocolViolationException',
+ ],
+ ];
+ }
+
+ #[DataProvider('soapEnvelopes')]
+ public function testSoapMessageIsInvalid(
+ string $soapMessage,
+ string $exceptionMessage,
+ string $exceptionClassName,
+ ): void {
+ $casconfig = Configuration::loadFromArray($this->moduleConfig);
+ $samlRequest = $soapMessage;
+
+ $target = 'https://comanage-ioi-dev.workbench.incommon.org/ssp/module.php/cas/linkback.php?'
+ . 'stateId=_bd6b7a3d207ed26ea893f49e555515b5f839547b59%3A'
+ . 'https%3A%2F%2Fcomanage-ioi-dev.workbench.incommon.org%2Fssp%2Fmodule.php%2Fadmin%2Ftest%2Fcasserver';
+ $this->samlValidateRequest = Request::create(
+ uri: Module::getModuleURL('casserver/samlValidate'),
+ method: 'POST',
+ parameters: ['TARGET' => $target],
+ content: $samlRequest,
+ );
+
+ $cas30Controller = new Cas30Controller(
+ $this->sspConfig,
+ $casconfig,
+ );
+
+ // Exception expected
+ $this->expectException($exceptionClassName);
+ $this->expectExceptionMessage($exceptionMessage);
+
+ $cas30Controller->samlValidate($this->samlValidateRequest, $target);
+ }
+
+
+ /**
+ * @return void
+ * @throws \Exception
+ */
+ public function testCasValidateAndDeleteTicketThrowsException(): void
+ {
+ $casconfig = Configuration::loadFromArray($this->moduleConfig);
+ $samlRequest = <<
+
+
+
+ ST-892424614ae738153cf6fda6ea372f54489870b8eb
+
+
+
+SOAP;
+
+ $target = 'https://comanage-ioi-dev.workbench.incommon.org/ssp/module.php/cas/linkback.php?'
+ . 'stateId=_bd6b7a3d207ed26ea893f49e555515b5f839547b59%3A'
+ . 'https%3A%2F%2Fcomanage-ioi-dev.workbench.incommon.org%2Fssp%2Fmodule.php%2Fadmin%2Ftest%2Fcasserver';
+ $this->samlValidateRequest = Request::create(
+ uri: Module::getModuleURL('casserver/samlValidate'),
+ method: 'POST',
+ parameters: ['TARGET' => $target],
+ content: $samlRequest,
+ );
+
+ /** @psalm-suppress UndefinedMethod */
+ $this->ticketValidatorMock
+ ->expects($this->once())
+ ->method('validateAndDeleteTicket')
+ ->willThrowException(new \RuntimeException('Cas validateAndDeleteTicket failed'));
+
+ $cas30Controller = new Cas30Controller(
+ $this->sspConfig,
+ $casconfig,
+ $this->ticketValidatorMock,
+ );
+
+ // Exception expected
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('Cas validateAndDeleteTicket failed');
+
+ $cas30Controller->samlValidate($this->samlValidateRequest, $target);
+ }
+
+ /**
+ * @return void
+ * @throws \Exception
+ */
+ public function testUnableToLoadTicket(): void
+ {
+ $this->ticketStore->addTicket(['id' => $this->ticket['id']]);
+ // We add the ticket. We need to make
+
+ $ticketId = $this->ticket['id'];
+ $casconfig = Configuration::loadFromArray($this->moduleConfig);
+ $samlRequest = <<
+
+
+
+ $ticketId
+
+
+
+SOAP;
+
+ $target = 'https://myservice.com/abcd';
+ $this->samlValidateRequest = Request::create(
+ uri: Module::getModuleURL('casserver/samlValidate'),
+ method: 'POST',
+ parameters: ['TARGET' => $target],
+ content: $samlRequest,
+ );
+
+ /** @psalm-suppress UndefinedMethod */
+ $this->ticketValidatorMock
+ ->expects($this->once())
+ ->method('validateAndDeleteTicket')
+ ->willReturn('i am a string');
+
+ $cas30Controller = new Cas30Controller(
+ $this->sspConfig,
+ $casconfig,
+ $this->ticketValidatorMock,
+ );
+
+ // Exception expected
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('Error loading ticket');
+
+ $cas30Controller->samlValidate($this->samlValidateRequest, $target);
+ }
+
+ /**
+ * @return void
+ * @throws \Exception
+ */
+ public function testSuccessfullValidation(): void
+ {
+ $this->ticketStore->addTicket($this->ticket);
+ $ticketId = $this->ticket['id'];
+ $casconfig = Configuration::loadFromArray($this->moduleConfig);
+ $samlRequest = <<
+
+
+
+ $ticketId
+
+
+
+SOAP;
+
+ $target = 'https://myservice.com/abcd';
+ $this->samlValidateRequest = Request::create(
+ uri: Module::getModuleURL('casserver/samlValidate'),
+ method: 'POST',
+ parameters: ['TARGET' => $target],
+ content: $samlRequest,
+ );
+
+ $cas30Controller = new Cas30Controller(
+ $this->sspConfig,
+ $casconfig,
+ );
+
+ $resp = $cas30Controller->samlValidate($this->samlValidateRequest, $target);
+ $this->assertEquals($resp->getStatusCode(), Response::HTTP_OK);
+ $this->assertStringContainsString(
+ 'eduPersonPrincipalName@google.com',
+ $resp->getContent(),
+ );
+ }
+}
diff --git a/tests/src/Controller/LoginControllerTest.php b/tests/src/Controller/LoginControllerTest.php
new file mode 100644
index 00000000..96a36e78
--- /dev/null
+++ b/tests/src/Controller/LoginControllerTest.php
@@ -0,0 +1,323 @@
+authSimpleMock = $this->getMockBuilder(Simple::class)
+ ->disableOriginalConstructor()
+ ->onlyMethods(['getAuthData', 'isAuthenticated', 'login', 'getAuthDataArray'])
+ ->getMock();
+
+ $this->sspContainer = $this->getMockBuilder(SspContainer::class)
+ ->disableOriginalConstructor()
+ ->onlyMethods(['redirect'])
+ ->getMock();
+
+ $this->httpUtils = $this->getMockBuilder(Utils\HTTP::class)
+ ->disableOriginalConstructor()
+ ->onlyMethods(['redirectTrustedURL'])
+ ->getMock();
+
+ $this->sessionMock = $this->getMockBuilder(Session::class)
+ ->disableOriginalConstructor()
+ ->onlyMethods(['getSessionId'])
+ ->getMock();
+
+ $this->moduleConfig = [
+ 'ticketstore' => [
+ 'class' => 'casserver:FileSystemTicketStore', //Not intended for production
+ 'directory' => __DIR__ . '../../../../tests/ticketcache',
+ ],
+ 'authsource' => 'sso',
+ 'legal_service_urls' => [
+ 'https://example.com/ssp/module.php/cas/linkback.php',
+ ],
+ 'debugMode' => false,
+ ];
+
+ $this->sspConfig = Configuration::getConfig('config.php');
+ }
+
+ public static function setUpBeforeClass(): void
+ {
+ // Some of the constructs in this test cause a Configuration to be created prior to us
+ // setting the one we want to use for the test.
+ Configuration::clearInternalState();
+
+ // To make lib/SimpleSAML/Utils/HTTP::getSelfURL() work...
+ global $_SERVER;
+ $_SERVER['REQUEST_URI'] = '/';
+ }
+
+ public static function loginParameters(): array
+ {
+ return [
+ 'Wrong Service Url' => [
+ ['service' => 'http://not-legal'],
+ // phpcs:ignore Generic.Files.LineLength.TooLong
+ "Service parameter provided to CAS server is not listed as a legal service: [service] = 'http://not-legal'",
+ ],
+ 'Invalid Scope' => [
+ [
+ 'service' => 'https://example.com/ssp/module.php/cas/linkback.php',
+ 'scope' => 'illegalscope',
+ ],
+ "Scope parameter provided to CAS server is not listed as legal scope: [scope] = 'illegalscope'",
+
+ ],
+ ];
+ }
+
+ /**
+ * Test incorrect service url
+ * @throws \Exception
+ */
+ #[DataProvider('loginParameters')]
+ public function testLoginFails(array $params, string $message): void
+ {
+ $casconfig = Configuration::loadFromArray($this->moduleConfig);
+
+ $loginRequest = Request::create(
+ uri: Module::getModuleURL('casserver/login'),
+ parameters: $params,
+ );
+
+ $loginController = new LoginController(
+ $this->sspConfig,
+ $casconfig,
+ );
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage($message);
+
+ $loginController->login($loginRequest, ...$params);
+ }
+
+ public static function loginOnAuthenticateParameters(): array
+ {
+ return [
+ 'No EntityId Query Parameter' => [
+ ['service' => 'https://example.com/ssp/module.php/cas/linkback.php'],
+ [
+ 'ForceAuthn' => false,
+ 'isPassive' => false,
+ // phpcs:ignore Generic.Files.LineLength.TooLong
+ 'ReturnTo' => 'http://localhost/?service=https%3A%2F%2Fexample.com%2Fssp%2Fmodule.php%2Fcas%2Flinkback.php',
+ ],
+ [],
+ ],
+ 'With EntityId Set' => [
+ [
+ 'service' => 'https://example.com/ssp/module.php/cas/linkback.php',
+ 'entityId' => 'http://localhost/entityId/sso',
+ ],
+ [
+ 'saml:idp' => 'http://localhost/entityId/sso',
+ 'ForceAuthn' => false,
+ 'isPassive' => false,
+ // phpcs:ignore Generic.Files.LineLength.TooLong
+ 'ReturnTo' => 'http://localhost/?service=https%3A%2F%2Fexample.com%2Fssp%2Fmodule.php%2Fcas%2Flinkback.php&entityId=http%3A%2F%2Flocalhost%2FentityId%2Fsso',
+ ],
+ [],
+ ],
+ 'With Valid Scope List Set - More than 1 items' => [
+ [
+ 'service' => 'https://example.com/ssp/module.php/cas/linkback.php',
+ 'scope' => 'desktop',
+ ],
+ [
+ 'ForceAuthn' => false,
+ 'isPassive' => false,
+ // phpcs:ignore Generic.Files.LineLength.TooLong
+ 'ReturnTo' => 'http://localhost/?service=https%3A%2F%2Fexample.com%2Fssp%2Fmodule.php%2Fcas%2Flinkback.php&scope=desktop',
+ 'saml:IDPList' => [
+ 'http://localhost/entityId/sso/scope/A',
+ 'http://localhost/entityId/sso/scope/B',
+ ],
+ ],
+ [
+ 'desktop' => [
+ 'http://localhost/entityId/sso/scope/A',
+ 'http://localhost/entityId/sso/scope/B',
+ ],
+ ],
+
+ ],
+ 'With Valid Scope List Set - 1 item' => [
+ [
+ 'service' => 'https://example.com/ssp/module.php/cas/linkback.php',
+ 'scope' => 'desktop',
+ ],
+ [
+ 'ForceAuthn' => false,
+ 'isPassive' => false,
+ // phpcs:ignore Generic.Files.LineLength.TooLong
+ 'ReturnTo' => 'http://localhost/?service=https%3A%2F%2Fexample.com%2Fssp%2Fmodule.php%2Fcas%2Flinkback.php&scope=desktop',
+ 'saml:idp' => 'http://localhost/entityId/sso/scope/A',
+ ],
+ [
+ 'desktop' => ['http://localhost/entityId/sso/scope/A'],
+ ],
+
+ ],
+ ];
+ }
+
+ #[DataProvider('loginOnAuthenticateParameters')]
+ public function testAuthSourceLogin(array $requestParameters, array $loginParameters, array $scopes): void
+ {
+ $moduleConfig = $this->moduleConfig;
+ $moduleConfig['scopes'] = $scopes;
+ $casconfig = Configuration::loadFromArray($moduleConfig);
+ $loginRequest = Request::create(
+ uri: Module::getModuleURL('casserver/login'),
+ parameters: $requestParameters,
+ );
+
+ $controllerMock = $this->getMockBuilder(LoginController::class)
+ ->setConstructorArgs([$this->sspConfig, $casconfig, $this->authSimpleMock, $this->httpUtils])
+ ->onlyMethods(['getSession'])
+ ->getMock();
+ $controllerMock->expects($this->once())->method('getSession')->willReturn($this->sessionMock);
+ $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false);
+ $sessionId = session_create_id();
+ $this->sessionMock->expects($this->once())->method('getSessionId')->willReturn($sessionId);
+
+ $response = $controllerMock->login($loginRequest, ...$requestParameters);
+ $this->assertInstanceOf(RunnableResponse::class, $response);
+ $callable = (array)$response->getCallable();
+ $this->assertEquals('login', $callable[1] ?? '');
+ }
+
+ /**
+ * Check authenticated with debugMode false
+ */
+ public function testIsAuthenticatedRedirectsToLoggedIn(): void
+ {
+ $casconfig = Configuration::loadFromArray($this->moduleConfig);
+ $controllerMock = $this->getMockBuilder(LoginController::class)
+ ->setConstructorArgs([$this->sspConfig, $casconfig, $this->authSimpleMock, $this->httpUtils])
+ ->onlyMethods(['getSession'])
+ ->getMock();
+
+ $sessionId = session_create_id();
+ $this->sessionMock->expects($this->exactly(2))->method('getSessionId')->willReturn($sessionId);
+
+ $controllerMock->expects($this->once())->method('getSession')->willReturn($this->sessionMock);
+ $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(true);
+ $this->authSimpleMock->expects($this->once())->method('getAuthData')->with('Expire')->willReturn(9999999999);
+
+ $loginRequest = Request::create(
+ uri: Module::getModuleURL('casserver/login'),
+ parameters: [],
+ );
+
+ $response = $controllerMock->login($loginRequest);
+ $this->assertInstanceOf(RunnableResponse::class, $response);
+ $callable = (array)$response->getCallable();
+ $this->assertEquals('redirectTrustedURL', $callable[1] ?? '');
+ }
+
+ public static function validServiceUrlProvider(): array
+ {
+ return [
+ "'ticket' Query Parameter" => [
+ 'service',
+ 'https://example.com/ssp/module.php/cas/linkback.php?ticket=ST-',
+ false,
+ ],
+ "'SAMLart' Query Parameter" => [
+ 'TARGET',
+ 'https://example.com/ssp/module.php/cas/linkback.php?SAMLart=ST-',
+ false,
+ ],
+ "'myTicket' Query Parameter for Ticket Name Override" => [
+ 'TARGET',
+ 'https://example.com/ssp/module.php/cas/linkback.php?SAMLart=ST-',
+ true,
+ ],
+ ];
+ }
+
+ /**
+ * Test a valid service URL
+ *
+ * @param string $serviceParam The name of the query parameter to use for the service url
+ * @param string $redirectURL
+ * @param bool $hasTicketNameOverride
+ *
+ * @throws \Exception
+ */
+ #[DataProvider('validServiceUrlProvider')]
+ public function testValidServiceUrl(string $serviceParam, string $redirectURL, bool $hasTicketNameOverride): void
+ {
+ $state['Attributes'] = [
+ 'eduPersonPrincipalName' => ['testuser@example.com'],
+ 'additionalAttribute' => ['Taco Club'],
+ 'Expire' => 9999999999,
+ ];
+ $moduleConfig = $this->moduleConfig;
+ if ($hasTicketNameOverride) {
+ $moduleConfig['legal_service_urls']['http://changeTicketParam'] = [
+ 'ticketName' => 'myTicket',
+ ];
+ }
+ $casconfig = Configuration::loadFromArray($moduleConfig);
+ $controllerMock = $this->getMockBuilder(LoginController::class)
+ ->setConstructorArgs([$this->sspConfig, $casconfig, $this->authSimpleMock, $this->httpUtils])
+ ->onlyMethods(['getSession'])
+ ->getMock();
+
+ $sessionId = session_create_id();
+ $this->sessionMock->expects($this->exactly(2))->method('getSessionId')->willReturn($sessionId);
+ $this->authSimpleMock->expects($this->once())->method('getAuthData')->with('Expire')->willReturn(9999999999);
+ $this->authSimpleMock->expects($this->once())->method('getAuthDataArray')->willReturn($state);
+
+ $controllerMock->expects($this->once())->method('getSession')->willReturn($this->sessionMock);
+ $this->authSimpleMock->expects($this->any())->method('isAuthenticated')->willReturn(true);
+ $queryParameters = [$serviceParam => 'https://example.com/ssp/module.php/cas/linkback.php'];
+ $loginRequest = Request::create(
+ uri: Module::getModuleURL('casserver/login'),
+ parameters: $queryParameters,
+ );
+
+ /** @psalm-suppress InvalidArgument */
+ $response = $controllerMock->login($loginRequest, ...$queryParameters);
+ $this->assertInstanceOf(RunnableResponse::class, $response);
+ $arguments = $response->getArguments();
+ $this->assertEquals('https://example.com/ssp/module.php/cas/linkback.php', $arguments[0]);
+ $this->assertStringStartsWith('ST-', array_values($arguments[1])[0] ?? []);
+ $callable = (array)$response->getCallable();
+ $this->assertEquals('redirectTrustedURL', $callable[1] ?? '');
+ }
+}
diff --git a/tests/src/Controller/LogoutControllerTest.php b/tests/src/Controller/LogoutControllerTest.php
new file mode 100644
index 00000000..60d6490b
--- /dev/null
+++ b/tests/src/Controller/LogoutControllerTest.php
@@ -0,0 +1,224 @@
+authSimpleMock = $this->getMockBuilder(Simple::class)
+ ->disableOriginalConstructor()
+ ->onlyMethods(['logout', 'isAuthenticated'])
+ ->getMock();
+
+ $this->moduleConfig = [
+ 'ticketstore' => [
+ 'class' => 'casserver:FileSystemTicketStore', //Not intended for production
+ 'directory' => __DIR__ . '../../../../tests/ticketcache',
+ ],
+ ];
+
+ $this->httpUtils = new Utils\HTTP();
+ $this->sspConfig = Configuration::getConfig('config.php');
+ }
+
+ public static function setUpBeforeClass(): void
+ {
+ // Some of the constructs in this test cause a Configuration to be created prior to us
+ // setting the one we want to use for the test.
+ Configuration::clearInternalState();
+
+ // To make lib/SimpleSAML/Utils/HTTP::getSelfURL() work...
+ global $_SERVER;
+ $_SERVER['REQUEST_URI'] = '/';
+ }
+
+ public function testLogoutNotAllowed(): void
+ {
+ $this->moduleConfig['enable_logout'] = false;
+ $config = Configuration::loadFromArray($this->moduleConfig);
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('Logout not allowed');
+
+ $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock);
+ $controller->logout(Request::create('/'));
+ }
+
+ public function testLogoutNoRedirectUrlOnSkipLogout(): void
+ {
+ $this->moduleConfig['enable_logout'] = true;
+ $this->moduleConfig['skip_logout_page'] = true;
+ $config = Configuration::loadFromArray($this->moduleConfig);
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('Required URL query parameter [url] not provided. (CAS Server)');
+
+ $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock);
+ $controller->logout(Request::create('/'));
+ }
+
+ public function testLogoutWithRedirectUrlOnSkipLogout(): void
+ {
+ $this->moduleConfig['enable_logout'] = true;
+ $this->moduleConfig['skip_logout_page'] = true;
+ $config = Configuration::loadFromArray($this->moduleConfig);
+ $urlParam = 'https://example.com/test';
+
+ // Unauthenticated
+ $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false);
+
+ $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->httpUtils);
+
+ $logoutUrl = Module::getModuleURL('casserver/logout.php');
+
+ $request = Request::create(
+ uri: $logoutUrl,
+ parameters: ['url' => $urlParam],
+ );
+ $response = $controller->logout($request, $urlParam);
+
+ $callable = $response->getCallable();
+ $method = is_array($callable) ? $callable[1] : 'unknown';
+ $arguments = $response->getArguments();
+ $this->assertEquals('redirectTrustedURL', $method);
+ $this->assertEquals(200, $response->getStatusCode());
+ $this->assertEquals($urlParam, $arguments[0]);
+ }
+
+ public function testLogoutNoRedirectUrlOnNoSkipLogoutUnAuthenticated(): void
+ {
+ $this->moduleConfig['enable_logout'] = true;
+ $this->moduleConfig['skip_logout_page'] = false;
+ $config = Configuration::loadFromArray($this->moduleConfig);
+
+ // Unauthenticated
+ $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false);
+
+ $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->httpUtils);
+ $response = $controller->logout(Request::create('/'));
+
+ $callable = $response->getCallable();
+ $method = is_array($callable) ? $callable[1] : 'unknown';
+ $arguments = $response->getArguments();
+ $this->assertEquals('redirectTrustedURL', $method);
+ $this->assertEquals(200, $response->getStatusCode());
+ $this->assertEquals('http://localhost/module.php/casserver/loggedOut', $arguments[0]);
+ }
+
+ public function testLogoutWithRedirectUrlOnNoSkipLogoutUnAuthenticated(): void
+ {
+ $this->moduleConfig['enable_logout'] = true;
+ $this->moduleConfig['skip_logout_page'] = false;
+ $config = Configuration::loadFromArray($this->moduleConfig);
+ $urlParam = 'https://example.com/test';
+ $logoutUrl = Module::getModuleURL('casserver/loggedOut');
+
+ // Unauthenticated
+ $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false);
+
+ $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->httpUtils);
+ $request = Request::create(
+ uri: $logoutUrl,
+ parameters: ['url' => $urlParam],
+ );
+ $response = $controller->logout($request, $urlParam);
+
+ $callable = $response->getCallable();
+ $method = is_array($callable) ? $callable[1] : 'unknown';
+ $arguments = $response->getArguments();
+ $this->assertEquals('redirectTrustedURL', $method);
+ $this->assertEquals(200, $response->getStatusCode());
+ $this->assertEquals('http://localhost/module.php/casserver/loggedOut', $arguments[0]);
+ }
+
+ public function testLogoutNoRedirectUrlOnNoSkipLogoutAuthenticated(): void
+ {
+ $this->moduleConfig['enable_logout'] = true;
+ $this->moduleConfig['skip_logout_page'] = false;
+ $config = Configuration::loadFromArray($this->moduleConfig);
+
+ // Unauthenticated
+ $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(true);
+
+ $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->httpUtils);
+ $queryParameters = ['url' => 'http://localhost/module.php/casserver/loggedOut'];
+ $logoutRequest = Request::create(
+ uri: Module::getModuleURL('casserver/loggedOut'),
+ parameters: $queryParameters,
+ );
+
+ $response = $controller->logout($logoutRequest, ...$queryParameters);
+
+ $this->assertInstanceOf(RunnableResponse::class, $response);
+ $callable = (array)$response->getCallable();
+ $this->assertEquals('logout', $callable[1] ?? '');
+ }
+
+ public function testTicketIdGetsDeletedOnLogout(): void
+ {
+ $this->moduleConfig['enable_logout'] = true;
+ $this->moduleConfig['skip_logout_page'] = false;
+ $config = Configuration::loadFromArray($this->moduleConfig);
+
+ $controllerMock = $this->getMockBuilder(LogoutController::class)
+ ->setConstructorArgs([$this->sspConfig, $config, $this->authSimpleMock, $this->httpUtils])
+ ->onlyMethods(['getSession'])
+ ->getMock();
+
+ $ticketStore = $controllerMock->getTicketStore();
+ $sessionMock = $this->getMockBuilder(Session::class)
+ ->disableOriginalConstructor()
+ ->onlyMethods(['getSessionId'])
+ ->getMock();
+
+ $sessionId = session_create_id();
+ $sessionMock->expects($this->once())->method('getSessionId')->willReturn($sessionId);
+
+ $ticket = [
+ 'id' => $sessionId,
+ // phpcs:ignore Generic.Files.LineLength.TooLong
+ 'service' => 'https://localhost/ssp/module.php/cas/linkback.php?stateId=_332b2b157041f4fc70dd290339a05a4e915674c1f2%3Ahttps%3A%2F%2Flocalhost%2Fssp%2Fmodule.php%2Fadmin%2Ftest%2Fcasserver',
+ 'forceAuthn' => false,
+ 'userName' => 'test1@google.com',
+ 'attributes' =>
+ [
+ 'eduPersonPrincipalName' =>
+ [
+ 0 => 'test@google.com',
+ ],
+ ],
+ 'proxies' =>
+ [
+ ],
+ ];
+
+ $ticketStore->addTicket($ticket);
+ $controllerMock->expects($this->once())->method('getSession')->willReturn($sessionMock);
+
+ $controllerMock->logout(Request::create('/'));
+ // The Ticket has been successfully deleted
+ $this->assertEquals(null, $ticketStore->getTicket($ticket['id']));
+ }
+}
diff --git a/tests/public/UtilsTest.php b/tests/src/Controller/Traits/UrlTraitTest.php
similarity index 58%
rename from tests/public/UtilsTest.php
rename to tests/src/Controller/Traits/UrlTraitTest.php
index 138b94a3..25b5dbb1 100644
--- a/tests/public/UtilsTest.php
+++ b/tests/src/Controller/Traits/UrlTraitTest.php
@@ -1,51 +1,17 @@
assertEquals($allowed, checkServiceURL(sanitize($service), $legalServices), "$service validated wrong");
- }
-
+ use UrlTrait;
/**
* @return array
@@ -87,4 +53,78 @@ public static function checkServiceURLProvider(): array
['', false],
];
}
+
+ /**
+ * @param string $service the service url to check
+ * @param bool $allowed is the service url allowed?
+ */
+ #[DataProvider('checkServiceURLProvider')]
+ public function testCheckServiceURL(string $service, bool $allowed): void
+ {
+ $legalServices = [
+ // Regular prefix match
+ 'https://myservice.com',
+ 'https://anotherservice.com/',
+ 'https://anotherservice.com:8080/',
+ 'http://sub.domain.com/path/a/b/c',
+ 'https://query.param/secure?apple=red',
+ 'https://encode.com/space test/',
+
+ // Regex match
+ '|^https://.*\.subdomain.com/|',
+ '#^https://.*-someprefix.com/#',
+
+ // Invalid settings don't blow up
+ '|invalid-regex',
+ '',
+ ];
+
+ $this->assertEquals(
+ $allowed,
+ $this->checkServiceURL($this->sanitize($service), $legalServices),
+ "$service validated wrong",
+ );
+ }
+
+ public static function requestParameters(): array
+ {
+ return [
+ [
+ ['renew' => true, 'language' => 'Greek', 'debugMode' => true],
+ ['renew' => true, 'language' => 'Greek', 'debugMode' => true],
+ [],
+ ],
+ [
+ ['renew' => true, 'language' => 'Greek', 'debugMode' => true],
+ ['renew' => true, 'language' => 'Greek', 'debugMode' => true, 'renewId' => '1234'],
+ [
+ 'renewId' => '1234',
+ ],
+ ],
+ [
+ ['renew' => true, 'language' => 'Greek', 'debugMode' => true, 'TARGET' => 'http://localhost'],
+ [
+ 'renew' => true,
+ 'language' => 'Greek',
+ 'debugMode' => true,
+ 'renewId' => '1234',
+ 'TARGET' => 'http://localhost',
+ ],
+ [
+ 'renewId' => '1234',
+ ],
+ ],
+ ];
+ }
+
+ #[DataProvider('requestParameters')]
+ public function testParseQueryParameters(array $requestParams, array $query, array $sessionTicket): void
+ {
+ $request = Request::create(
+ uri: '/',
+ parameters: $requestParams,
+ );
+
+ $this->assertEquals($query, $this->parseQueryParameters($request, $sessionTicket));
+ }
}