simplesamlphp /
simplesamlphp-module-cas
| 1 | <?php |
||||
| 2 | |||||
| 3 | declare(strict_types=1); |
||||
| 4 | |||||
| 5 | namespace SimpleSAML\Module\cas\Auth\Source; |
||||
| 6 | |||||
| 7 | use DOMDocument; |
||||
| 8 | use DOMElement; |
||||
| 9 | use Exception; |
||||
| 10 | use SimpleSAML\Auth; |
||||
| 11 | use SimpleSAML\CAS\Utils\XPath; |
||||
| 12 | use SimpleSAML\CAS\XML\AuthenticationFailure; |
||||
| 13 | use SimpleSAML\CAS\XML\AuthenticationSuccess as CasAuthnSuccess; |
||||
| 14 | use SimpleSAML\CAS\XML\ServiceResponse as CasServiceResponse; |
||||
| 15 | use SimpleSAML\Configuration; |
||||
| 16 | use SimpleSAML\Logger; |
||||
| 17 | use SimpleSAML\Module; |
||||
| 18 | use SimpleSAML\Module\ldap\Auth\Ldap; |
||||
| 19 | use SimpleSAML\Slate\XML\AuthenticationSuccess as SlateAuthnSuccess; |
||||
| 20 | use SimpleSAML\Slate\XML\ServiceResponse as SlateServiceResponse; |
||||
| 21 | use SimpleSAML\Utils; |
||||
| 22 | use SimpleSAML\XML\Chunk; |
||||
| 23 | use SimpleSAML\XML\DOMDocumentFactory; |
||||
| 24 | use Symfony\Component\HttpClient\HttpClient; |
||||
| 25 | use Symfony\Contracts\HttpClient\HttpClientInterface; |
||||
| 26 | |||||
| 27 | use function array_merge_recursive; |
||||
| 28 | use function preg_split; |
||||
| 29 | use function strcmp; |
||||
| 30 | use function strval; |
||||
| 31 | use function var_export; |
||||
| 32 | |||||
| 33 | /** |
||||
| 34 | * Authenticate using CAS. |
||||
| 35 | * |
||||
| 36 | * Based on www/auth/login-cas.php by Mads Freek, RUC. |
||||
| 37 | * |
||||
| 38 | * @package SimpleSAMLphp |
||||
| 39 | */ |
||||
| 40 | |||||
| 41 | class CAS extends Auth\Source |
||||
| 42 | { |
||||
| 43 | /** |
||||
| 44 | * The string used to identify our states. |
||||
| 45 | */ |
||||
| 46 | public const STAGE_INIT = '\SimpleSAML\Module\cas\Auth\Source\CAS.state'; |
||||
| 47 | |||||
| 48 | /** |
||||
| 49 | * The key of the AuthId field in the state. |
||||
| 50 | */ |
||||
| 51 | public const AUTHID = '\SimpleSAML\Module\cas\Auth\Source\CAS.AuthId'; |
||||
| 52 | |||||
| 53 | |||||
| 54 | /** |
||||
| 55 | * @var array<string, mixed> with ldap configuration |
||||
| 56 | */ |
||||
| 57 | private array $ldapConfig; |
||||
| 58 | |||||
| 59 | /** |
||||
| 60 | * @var array<string, mixed> cas configuration |
||||
| 61 | */ |
||||
| 62 | private array $casConfig; |
||||
| 63 | |||||
| 64 | /** |
||||
| 65 | * @var string cas chosen validation method |
||||
| 66 | */ |
||||
| 67 | |||||
| 68 | private string $validationMethod; |
||||
| 69 | |||||
| 70 | /** |
||||
| 71 | * @var string cas login method |
||||
| 72 | */ |
||||
| 73 | private string $loginMethod; |
||||
| 74 | |||||
| 75 | /** |
||||
| 76 | * @var bool flag indicating if slate XML format should be used |
||||
| 77 | */ |
||||
| 78 | private bool $useSlate; |
||||
| 79 | |||||
| 80 | /** |
||||
| 81 | * HTTP utilities instance for handling redirects and URLs. |
||||
| 82 | */ |
||||
| 83 | private Utils\HTTP $httpUtils; |
||||
| 84 | |||||
| 85 | /** |
||||
| 86 | * Symfony HTTP client for CAS requests. |
||||
| 87 | */ |
||||
| 88 | private HttpClientInterface $httpClient; |
||||
| 89 | |||||
| 90 | |||||
| 91 | /** |
||||
| 92 | * Constructor for this authentication source. |
||||
| 93 | * |
||||
| 94 | * @param array<mixed> $info Information about this authentication source. |
||||
| 95 | * @param array<mixed> $config Configuration. |
||||
| 96 | */ |
||||
| 97 | public function __construct(array $info, array $config) |
||||
| 98 | { |
||||
| 99 | // Call the parent constructor first, as required by the interface |
||||
| 100 | parent::__construct($info, $config); |
||||
| 101 | |||||
| 102 | $authsources = Configuration::loadFromArray($config); |
||||
| 103 | |||||
| 104 | $this->casConfig = (array)$authsources->getValue('cas'); |
||||
| 105 | $this->ldapConfig = (array)$authsources->getValue('ldap'); |
||||
| 106 | |||||
| 107 | if (isset($this->casConfig['serviceValidate'])) { |
||||
| 108 | $this->validationMethod = 'serviceValidate'; |
||||
| 109 | } elseif (isset($this->casConfig['validate'])) { |
||||
| 110 | $this->validationMethod = 'validate'; |
||||
| 111 | } else { |
||||
| 112 | throw new Exception("validate or serviceValidate not specified"); |
||||
| 113 | } |
||||
| 114 | |||||
| 115 | if (isset($this->casConfig['login'])) { |
||||
| 116 | $this->loginMethod = $this->casConfig['login']; |
||||
| 117 | } else { |
||||
| 118 | throw new Exception("cas login URL not specified"); |
||||
| 119 | } |
||||
| 120 | |||||
| 121 | $this->useSlate = $this->casConfig['slate.enabled'] ?? false; |
||||
| 122 | } |
||||
| 123 | |||||
| 124 | |||||
| 125 | /** |
||||
| 126 | * Initialize HttpClient instance |
||||
| 127 | * |
||||
| 128 | * @param \Symfony\Contracts\HttpClient\HttpClientInterface|null $httpClient Optional HTTP client instance to use |
||||
| 129 | */ |
||||
| 130 | protected function initHttpClient(?HttpClientInterface $httpClient = null): void |
||||
| 131 | { |
||||
| 132 | if ($httpClient !== null) { |
||||
| 133 | $this->httpClient = $httpClient; |
||||
| 134 | } else { |
||||
| 135 | $this->httpClient = $this->httpClient ?? HttpClient::create(); |
||||
| 136 | } |
||||
| 137 | } |
||||
| 138 | |||||
| 139 | |||||
| 140 | /** |
||||
| 141 | * Initialize HTTP utilities instance |
||||
| 142 | * |
||||
| 143 | * @param \SimpleSAML\Utils\HTTP|null $httpUtils Optional HTTP utilities instance to use |
||||
| 144 | * @return void |
||||
| 145 | * @deprecated This helper is kept only for the legacy authenticate(array &$state): void |
||||
| 146 | * flow. Once the Request-based authenticate(Request, array &$state): ?Response |
||||
| 147 | * API is active in SimpleSAMLphp, this method will be removed and HTTP |
||||
| 148 | * handling should be done via Symfony responses instead. |
||||
| 149 | */ |
||||
| 150 | protected function initHttpUtils(?Utils\HTTP $httpUtils = null): void |
||||
| 151 | { |
||||
| 152 | if ($httpUtils !== null) { |
||||
| 153 | $this->httpUtils = $httpUtils; |
||||
| 154 | } else { |
||||
| 155 | $this->httpUtils = $this->httpUtils ?? new Utils\HTTP(); |
||||
| 156 | } |
||||
| 157 | } |
||||
| 158 | |||||
| 159 | |||||
| 160 | /** |
||||
| 161 | * This the most simple version of validating, this provides only authentication validation |
||||
| 162 | * |
||||
| 163 | * @param string $ticket |
||||
| 164 | * @param string $service |
||||
| 165 | * |
||||
| 166 | * @return array<mixed> username and attributes |
||||
| 167 | */ |
||||
| 168 | private function casValidate(string $ticket, string $service): array |
||||
| 169 | { |
||||
| 170 | $this->initHttpClient(); |
||||
| 171 | |||||
| 172 | $response = $this->httpClient->request('GET', $this->casConfig['validate'], [ |
||||
| 173 | 'query' => [ |
||||
| 174 | 'ticket' => $ticket, |
||||
| 175 | 'service' => $service, |
||||
| 176 | ], |
||||
| 177 | ]); |
||||
| 178 | |||||
| 179 | $result = $response->getContent(); |
||||
| 180 | |||||
| 181 | /** @var list<string> $res */ |
||||
| 182 | $res = preg_split("/\r?\n/", $result) ?: []; |
||||
| 183 | |||||
| 184 | if (strcmp($res[0], "yes") == 0) { |
||||
| 185 | return [$res[1], []]; |
||||
| 186 | } else { |
||||
| 187 | throw new Exception("Failed to validate CAS service ticket: $ticket"); |
||||
| 188 | } |
||||
| 189 | } |
||||
| 190 | |||||
| 191 | |||||
| 192 | /** |
||||
| 193 | * Uses the cas service validate, this provides additional attributes |
||||
| 194 | * |
||||
| 195 | * @param string $ticket |
||||
| 196 | * @param string $service |
||||
| 197 | * |
||||
| 198 | * @return array<mixed> username and attributes |
||||
| 199 | */ |
||||
| 200 | private function casServiceValidate(string $ticket, string $service): array |
||||
| 201 | { |
||||
| 202 | $this->initHttpClient(); |
||||
| 203 | |||||
| 204 | $response = $this->httpClient->request('GET', $this->casConfig['serviceValidate'], [ |
||||
| 205 | 'query' => [ |
||||
| 206 | 'ticket' => $ticket, |
||||
| 207 | 'service' => $service, |
||||
| 208 | ], |
||||
| 209 | ]); |
||||
| 210 | |||||
| 211 | $result = $response->getContent(); |
||||
| 212 | |||||
| 213 | /** @var string $result */ |
||||
| 214 | $dom = DOMDocumentFactory::fromString($result); |
||||
| 215 | |||||
| 216 | // In practice that `if (...) return [];` branch is unreachable with the current behavior. |
||||
| 217 | // `DOMDocumentFactory::fromString()` |
||||
| 218 | // PHPStan still flags / cares about it because it only sees |
||||
| 219 | // and has no way to know `null` won’t actually occur here. `DOMElement|null` |
||||
| 220 | if ($dom->documentElement === null) { |
||||
| 221 | return []; |
||||
| 222 | } |
||||
| 223 | |||||
| 224 | if ($this->useSlate) { |
||||
| 225 | $serviceResponse = SlateServiceResponse::fromXML($dom->documentElement); |
||||
| 226 | } else { |
||||
| 227 | $serviceResponse = CasServiceResponse::fromXML($dom->documentElement); |
||||
| 228 | } |
||||
| 229 | |||||
| 230 | $message = $serviceResponse->getResponse(); |
||||
| 231 | if ($message instanceof AuthenticationFailure) { |
||||
| 232 | throw new Exception(sprintf( |
||||
| 233 | "Error when validating CAS service ticket: %s (%s)", |
||||
| 234 | strval($message->getContent()), |
||||
| 235 | strval($message->getCode()), |
||||
| 236 | )); |
||||
| 237 | } elseif ($message instanceof CasAuthnSuccess || $message instanceof SlateAuthnSuccess) { |
||||
| 238 | [$user, $attributes] = $this->parseAuthenticationSuccess($message); |
||||
| 239 | |||||
| 240 | // This will only be parsed if i have an attribute query. If the configuration |
||||
| 241 | // array is empty or not set then an empty array will be returned. |
||||
| 242 | $attributesFromQueryConfiguration = $this->parseQueryAttributes($dom); |
||||
| 243 | if (!empty($attributesFromQueryConfiguration)) { |
||||
| 244 | // Overwrite attributes from parseAuthenticationSuccess with configured |
||||
| 245 | // XPath-based attributes, instead of combining them. |
||||
| 246 | foreach ($attributesFromQueryConfiguration as $name => $values) { |
||||
| 247 | // Ensure a clean, unique list of string values |
||||
| 248 | $values = array_values(array_unique(array_map('strval', $values))); |
||||
| 249 | |||||
| 250 | // Configuration wins: replace any existing attribute with the same name |
||||
| 251 | $attributes[$name] = $values; |
||||
| 252 | } |
||||
| 253 | } |
||||
| 254 | |||||
| 255 | return [$user, $attributes]; |
||||
| 256 | } |
||||
| 257 | |||||
| 258 | throw new Exception("Error parsing serviceResponse."); |
||||
| 259 | } |
||||
| 260 | |||||
| 261 | |||||
| 262 | /** |
||||
| 263 | * Main validation method, redirects to the correct method |
||||
| 264 | * (keeps finalStep clean) |
||||
| 265 | * |
||||
| 266 | * @param string $ticket |
||||
| 267 | * @param string $service |
||||
| 268 | * @return array<mixed> username and attributes |
||||
| 269 | */ |
||||
| 270 | protected function casValidation(string $ticket, string $service): array |
||||
| 271 | { |
||||
| 272 | switch ($this->validationMethod) { |
||||
| 273 | case 'validate': |
||||
| 274 | return $this->casValidate($ticket, $service); |
||||
| 275 | case 'serviceValidate': |
||||
| 276 | return $this->casServiceValidate($ticket, $service); |
||||
| 277 | default: |
||||
| 278 | throw new Exception("validate or serviceValidate not specified"); |
||||
| 279 | } |
||||
| 280 | } |
||||
| 281 | |||||
| 282 | |||||
| 283 | /** |
||||
| 284 | * Called by linkback, to finish validate/ finish logging in. |
||||
| 285 | * @param array<mixed> $state |
||||
| 286 | */ |
||||
| 287 | public function finalStep(array &$state): void |
||||
| 288 | { |
||||
| 289 | $ticket = $state['cas:ticket']; |
||||
| 290 | $stateId = Auth\State::saveState($state, self::STAGE_INIT); |
||||
| 291 | $service = Module::getModuleURL('cas/linkback.php', ['stateId' => $stateId]); |
||||
| 292 | list($username, $casAttributes) = $this->casValidation($ticket, $service); |
||||
| 293 | $ldapAttributes = []; |
||||
| 294 | |||||
| 295 | $config = Configuration::loadFromArray( |
||||
| 296 | $this->ldapConfig, |
||||
| 297 | 'Authentication source ' . var_export($this->authId, true), |
||||
| 298 | ); |
||||
| 299 | if (!empty($this->ldapConfig['servers'])) { |
||||
| 300 | $ldap = new Ldap( |
||||
| 301 | $config->getString('servers'), |
||||
| 302 | $config->getOptionalBoolean('enable_tls', false), |
||||
|
0 ignored issues
–
show
Bug
introduced
by
Loading history...
|
|||||
| 303 | $config->getOptionalBoolean('debug', false), |
||||
|
0 ignored issues
–
show
It seems like
$config->getOptionalBoolean('debug', false) can also be of type null; however, parameter $debug of SimpleSAML\Module\ldap\Auth\Ldap::__construct() does only seem to accept boolean, maybe add an additional type check?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
Loading history...
|
|||||
| 304 | $config->getOptionalInteger('timeout', 0), |
||||
| 305 | $config->getOptionalInteger('port', 389), |
||||
| 306 | $config->getOptionalBoolean('referrals', true), |
||||
|
0 ignored issues
–
show
It seems like
$config->getOptionalBoolean('referrals', true) can also be of type null; however, parameter $referrals of SimpleSAML\Module\ldap\Auth\Ldap::__construct() does only seem to accept boolean, maybe add an additional type check?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
Loading history...
|
|||||
| 307 | ); |
||||
| 308 | |||||
| 309 | $ldapAttributes = $ldap->validate($this->ldapConfig, $username); |
||||
| 310 | if ($ldapAttributes === false) { |
||||
|
0 ignored issues
–
show
|
|||||
| 311 | throw new Exception("Failed to authenticate against LDAP-server."); |
||||
| 312 | } |
||||
| 313 | } |
||||
| 314 | $attributes = array_merge_recursive($casAttributes, $ldapAttributes); |
||||
| 315 | $state['Attributes'] = $attributes; |
||||
| 316 | } |
||||
| 317 | |||||
| 318 | |||||
| 319 | /** |
||||
| 320 | * Log-in using cas |
||||
| 321 | * |
||||
| 322 | * @param array<mixed> &$state Information about the current authentication. |
||||
| 323 | */ |
||||
| 324 | public function authenticate(array &$state): void |
||||
| 325 | { |
||||
| 326 | // We are going to need the authId in order to retrieve this authentication source later |
||||
| 327 | $state[self::AUTHID] = $this->authId; |
||||
| 328 | |||||
| 329 | $stateId = Auth\State::saveState($state, self::STAGE_INIT); |
||||
| 330 | |||||
| 331 | $serviceUrl = Module::getModuleURL('cas/linkback.php', ['stateId' => $stateId]); |
||||
| 332 | |||||
| 333 | $this->initHttpUtils(); |
||||
|
0 ignored issues
–
show
The function
SimpleSAML\Module\cas\Au...ce\CAS::initHttpUtils() has been deprecated: This helper is kept only for the legacy authenticate(array &$state): void flow. Once the Request-based authenticate(Request, array &$state): ?Response API is active in SimpleSAMLphp, this method will be removed and HTTP handling should be done via Symfony responses instead.
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
This function has been deprecated. The supplier of the function has supplied an explanatory message. The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead. Loading history...
|
|||||
| 334 | $this->httpUtils->redirectTrustedURL($this->loginMethod, ['service' => $serviceUrl]); |
||||
| 335 | } |
||||
| 336 | |||||
| 337 | |||||
| 338 | /** |
||||
| 339 | * Log out from this authentication source. |
||||
| 340 | * |
||||
| 341 | * This function should be overridden if the authentication source requires special |
||||
| 342 | * steps to complete a logout operation. |
||||
| 343 | * |
||||
| 344 | * If the logout process requires a redirect, the state should be saved. Once the |
||||
| 345 | * logout operation is completed, the state should be restored, and completeLogout |
||||
| 346 | * should be called with the state. If this operation can be completed without |
||||
| 347 | * showing the user a page, or redirecting, this function should return. |
||||
| 348 | * |
||||
| 349 | * @param array<mixed> &$state Information about the current logout operation. |
||||
| 350 | */ |
||||
| 351 | public function logout(array &$state): void |
||||
| 352 | { |
||||
| 353 | $logoutUrl = $this->casConfig['logout']; |
||||
| 354 | |||||
| 355 | Auth\State::deleteState($state); |
||||
| 356 | |||||
| 357 | // we want cas to log us out |
||||
| 358 | $this->initHttpUtils(); |
||||
|
0 ignored issues
–
show
The function
SimpleSAML\Module\cas\Au...ce\CAS::initHttpUtils() has been deprecated: This helper is kept only for the legacy authenticate(array &$state): void flow. Once the Request-based authenticate(Request, array &$state): ?Response API is active in SimpleSAMLphp, this method will be removed and HTTP handling should be done via Symfony responses instead.
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
This function has been deprecated. The supplier of the function has supplied an explanatory message. The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead. Loading history...
|
|||||
| 359 | $this->httpUtils->redirectTrustedURL($logoutUrl); |
||||
| 360 | } |
||||
| 361 | |||||
| 362 | |||||
| 363 | /** |
||||
| 364 | * Parse a CAS AuthenticationSuccess into a flat associative array. |
||||
| 365 | * |
||||
| 366 | * Rules: |
||||
| 367 | * - 'user' => content |
||||
| 368 | * - For each attribute element (Chunk): |
||||
| 369 | * - If prefix is 'cas' or empty => key is localName |
||||
| 370 | * - Else => key is "prefix:localName" |
||||
| 371 | * - Value is the element's textContent |
||||
| 372 | * - If multiple values for the same key, collect into array |
||||
| 373 | * |
||||
| 374 | * @param \SimpleSAML\CAS\XML\AuthenticationSuccess|\SimpleSAML\Slate\XML\AuthenticationSuccess $message |
||||
| 375 | * The authentication success message to parse |
||||
| 376 | * @return array{ |
||||
|
0 ignored issues
–
show
|
|||||
| 377 | * 0: \SimpleSAML\XMLSchema\Type\Interface\ValueTypeInterface, |
||||
| 378 | * 1: array<string, list<string>> |
||||
| 379 | * } |
||||
| 380 | */ |
||||
| 381 | private function parseAuthenticationSuccess(CasAuthnSuccess|SlateAuthnSuccess $message): array |
||||
| 382 | { |
||||
| 383 | /** @var array<string, list<string>> $result */ |
||||
| 384 | $result = []; |
||||
| 385 | |||||
| 386 | // user -> content |
||||
| 387 | $user = $message->getUser()->getContent(); |
||||
| 388 | |||||
| 389 | // attributes -> elements (array of SimpleSAML\XML\Chunk) |
||||
| 390 | $attributes = $message->getAttributes(); |
||||
| 391 | /** @var list<\SimpleSAML\XML\Chunk> $elements */ |
||||
| 392 | $elements = $attributes->getElements(); |
||||
| 393 | |||||
| 394 | foreach ($elements as $chunk) { |
||||
| 395 | // Safely extract localName, prefix, and DOMElement from the Chunk |
||||
| 396 | $localName = $chunk->getLocalName(); |
||||
| 397 | $prefix = $chunk->getPrefix(); |
||||
| 398 | // DOMElement carrying the actual text content |
||||
| 399 | $xmlElement = $chunk->getXML(); |
||||
| 400 | |||||
| 401 | if (!$localName) { |
||||
| 402 | continue; // skip malformed entries |
||||
| 403 | } |
||||
| 404 | |||||
| 405 | // Key selection rule |
||||
| 406 | $key = ($prefix === '' || $prefix === 'cas') |
||||
| 407 | ? $localName |
||||
| 408 | : ($prefix . ':' . $localName); |
||||
| 409 | |||||
| 410 | $value = trim($xmlElement->textContent ?? ''); |
||||
| 411 | |||||
| 412 | // Collect values (single or multi) |
||||
| 413 | $result[$key] ??= []; |
||||
| 414 | $result[$key][] = $value; |
||||
| 415 | } |
||||
| 416 | |||||
| 417 | // (DOMElement instances under cas:authenticationSuccess, outside cas:attributes) |
||||
| 418 | $this->parseAuthenticationSuccessMetadata($message, $result); |
||||
| 419 | |||||
| 420 | return [$user, $result]; |
||||
| 421 | } |
||||
| 422 | |||||
| 423 | |||||
| 424 | /** |
||||
| 425 | * Parse metadata elements from AuthenticationSuccess message and add them to attributes array |
||||
| 426 | * |
||||
| 427 | * @param \SimpleSAML\CAS\XML\AuthenticationSuccess|\SimpleSAML\Slate\XML\AuthenticationSuccess $message |
||||
| 428 | * The authentication success message |
||||
| 429 | * @param array<string,list<string>> &$attributes Reference to attributes array to update |
||||
|
0 ignored issues
–
show
|
|||||
| 430 | * @return void |
||||
| 431 | */ |
||||
| 432 | private function parseAuthenticationSuccessMetadata( |
||||
| 433 | CasAuthnSuccess|SlateAuthnSuccess $message, |
||||
| 434 | array &$attributes, |
||||
| 435 | ): void { |
||||
| 436 | if (!method_exists($message, 'getElements')) { |
||||
| 437 | // Either bail out or use a fallback |
||||
| 438 | return; |
||||
| 439 | } |
||||
| 440 | |||||
| 441 | $metaElements = $message->getElements(); |
||||
| 442 | |||||
| 443 | foreach ($metaElements as $element) { |
||||
| 444 | if (!$element instanceof Chunk) { |
||||
| 445 | continue; |
||||
| 446 | } |
||||
| 447 | |||||
| 448 | $localName = $element->getLocalName(); |
||||
| 449 | $prefix = $element->getPrefix(); |
||||
| 450 | |||||
| 451 | if ($localName === '') { |
||||
| 452 | continue; |
||||
| 453 | } |
||||
| 454 | |||||
| 455 | // For metadata elements we do NOT special-case 'cas': |
||||
| 456 | // we always use "prefix:localName" when there is a prefix, |
||||
| 457 | // and just localName when there is none. |
||||
| 458 | $key = ($prefix === '') |
||||
| 459 | ? $localName |
||||
| 460 | : ($prefix . ':' . $localName); |
||||
| 461 | |||||
| 462 | $value = trim($element->getXML()->textContent ?? ''); |
||||
| 463 | |||||
| 464 | $attributes[$key] ??= []; |
||||
| 465 | $attributes[$key][] = $value; |
||||
| 466 | } |
||||
| 467 | } |
||||
| 468 | |||||
| 469 | |||||
| 470 | /** |
||||
| 471 | * Parse metadata attributes from CAS response XML using configured XPath queries |
||||
| 472 | * |
||||
| 473 | * @param \DOMDocument $dom The XML document containing CAS response |
||||
| 474 | * @return array<string,array<string>> Array of metadata attribute names and values |
||||
| 475 | */ |
||||
| 476 | private function parseQueryAttributes(DOMDocument $dom): array |
||||
| 477 | { |
||||
| 478 | $root = $dom->documentElement; |
||||
| 479 | if (!$root instanceof DOMElement) { |
||||
|
0 ignored issues
–
show
|
|||||
| 480 | return []; |
||||
| 481 | } |
||||
| 482 | |||||
| 483 | $xPath = XPath::getXPath($root, true); |
||||
| 484 | |||||
| 485 | $metadata = []; |
||||
| 486 | $casattributes = $this->casConfig['attributes'] ?? null; |
||||
| 487 | if (!is_array($casattributes)) { |
||||
| 488 | return $metadata; |
||||
| 489 | } |
||||
| 490 | |||||
| 491 | /** @var list<\DOMElement> $authnNodes */ |
||||
| 492 | $authnNodes = XPath::xpQuery($root, 'cas:authenticationSuccess', $xPath); |
||||
| 493 | /** @var \DOMElement|null $authn */ |
||||
| 494 | $authn = $authnNodes[0] ?? null; |
||||
| 495 | |||||
| 496 | // Some have attributes in the xml - attributes is a list of XPath expressions to get them |
||||
| 497 | foreach ($casattributes as $name => $query) { |
||||
| 498 | $marker = 'cas:authenticationSuccess/'; |
||||
| 499 | |||||
| 500 | if (isset($query[0]) && $query[0] === '/') { |
||||
| 501 | // Absolute XPath |
||||
| 502 | if (strpos($query, $marker) !== false && $authn instanceof \DOMElement) { |
||||
| 503 | $originalQuery = $query; |
||||
| 504 | $query = substr($query, strpos($query, $marker) + strlen($marker)); |
||||
| 505 | Logger::info(sprintf( |
||||
| 506 | 'CAS client: rewriting absolute CAS XPath for "%s" from "%s" to relative "%s"', |
||||
| 507 | $name, |
||||
| 508 | $originalQuery, |
||||
| 509 | $query, |
||||
| 510 | )); |
||||
| 511 | $nodes = XPath::xpQuery($authn, $query, $xPath); |
||||
| 512 | } else { |
||||
| 513 | // Keep absolute; evaluate from document root |
||||
| 514 | $nodes = XPath::xpQuery($root, $query, $xPath); |
||||
| 515 | } |
||||
| 516 | } else { |
||||
| 517 | // Relative XPath; prefer evaluating under authenticationSuccess if available |
||||
| 518 | $context = $authn instanceof DOMElement ? $authn : $root; |
||||
| 519 | $nodes = XPath::xpQuery($context, $query, $xPath); |
||||
| 520 | } |
||||
| 521 | |||||
| 522 | foreach ($nodes as $n) { |
||||
| 523 | $metadata[$name][] = trim($n->textContent); |
||||
| 524 | } |
||||
| 525 | |||||
| 526 | Logger::debug(sprintf( |
||||
| 527 | 'CAS client: parsed metadata %s => %s', |
||||
| 528 | $name, |
||||
| 529 | json_encode($metadata[$name] ?? []), |
||||
| 530 | )); |
||||
| 531 | } |
||||
| 532 | |||||
| 533 | return $metadata; |
||||
| 534 | } |
||||
| 535 | } |
||||
| 536 |