diff --git a/src/CAS/XML/Attributes.php b/src/CAS/XML/Attributes.php index a60d52e..162c92f 100644 --- a/src/CAS/XML/Attributes.php +++ b/src/CAS/XML/Attributes.php @@ -39,15 +39,15 @@ final class Attributes extends AbstractCasElement /** * Initialize a cas:attributes element * - * @param \SimpleSAML\CAS\XML\AuthenticationDate $authenticationDate - * @param \SimpleSAML\CAS\XML\LongTermAuthenticationRequestTokenUsed $longTermAuthenticationRequestTokenUsed - * @param \SimpleSAML\CAS\XML\IsFromNewLogin $isFromNewLogin + * @param \SimpleSAML\CAS\XML\AuthenticationDate|null $authenticationDate + * @param \SimpleSAML\CAS\XML\LongTermAuthenticationRequestTokenUsed|null $longTermAuthenticationRequestTokenUsed + * @param \SimpleSAML\CAS\XML\IsFromNewLogin|null $isFromNewLogin * @param list<\SimpleSAML\XML\SerializableElementInterface> $elts */ final public function __construct( - protected AuthenticationDate $authenticationDate, - protected LongTermAuthenticationRequestTokenUsed $longTermAuthenticationRequestTokenUsed, - protected IsFromNewLogin $isFromNewLogin, + protected ?AuthenticationDate $authenticationDate, + protected ?LongTermAuthenticationRequestTokenUsed $longTermAuthenticationRequestTokenUsed, + protected ?IsFromNewLogin $isFromNewLogin, array $elts = [], ) { $this->setElements($elts); @@ -55,27 +55,27 @@ final public function __construct( /** - * @return \SimpleSAML\CAS\XML\AuthenticationDate + * @return \SimpleSAML\CAS\XML\AuthenticationDate|null */ - public function getAuthenticationDate(): AuthenticationDate + public function getAuthenticationDate(): ?AuthenticationDate { return $this->authenticationDate; } /** - * @return \SimpleSAML\CAS\XML\LongTermAuthenticationRequestTokenUsed + * @return \SimpleSAML\CAS\XML\LongTermAuthenticationRequestTokenUsed|null */ - public function getLongTermAuthenticationRequestTokenUsed(): LongTermAuthenticationRequestTokenUsed + public function getLongTermAuthenticationRequestTokenUsed(): ?LongTermAuthenticationRequestTokenUsed { return $this->longTermAuthenticationRequestTokenUsed; } /** - * @return \SimpleSAML\CAS\XML\IsFromNewLogin + * @return \SimpleSAML\CAS\XML\IsFromNewLogin|null */ - public function getIsFromNewLogin(): IsFromNewLogin + public function getIsFromNewLogin(): ?IsFromNewLogin { return $this->isFromNewLogin; } @@ -84,7 +84,16 @@ public function getIsFromNewLogin(): IsFromNewLogin /** * Convert XML into a cas:attributes-element * + * This parser supports a "lenient" mode for interoperability with CAS servers + * that omit core CAS attributes but still embed additional attributes, such as + * Technolutions Slate. In Slate's CAS responses, may only + * contain custom fields (e.g. cas:firstname, cas:lastname, cas:email) and + * omit the CAS 3.0 attributes authenticationDate, longTermAuthenticationRequestTokenUsed + * and isFromNewLogin. To allow consuming those responses, strict parsing is + * disabled by default and can be enabled explicitly via $strictParsing. + * * @param \DOMElement $xml The XML element we should load + * @param bool $strictParsing Whether to enforce CAS 3.0 required attributes * @return static * * @throws \SimpleSAML\XMLSchema\Exception\InvalidDOMElementException @@ -92,39 +101,39 @@ public function getIsFromNewLogin(): IsFromNewLogin * @throws \SimpleSAML\XMLSchema\Exception\MissingAttributeException * if the supplied element is missing one of the mandatory attributes */ - public static function fromXML(DOMElement $xml): static + public static function fromXML(DOMElement $xml, bool $strictParsing = false): static { Assert::same($xml->localName, static::getLocalName(), InvalidDOMElementException::class); Assert::same($xml->namespaceURI, static::getNamespaceURI(), InvalidDOMElementException::class); $authenticationDate = AuthenticationDate::getChildrenOfClass($xml); - Assert::count( + self::assertZeroOrOneOrExactlyOneWhenStrict( $authenticationDate, - 1, + $strictParsing, 'Exactly one must be specified.', - MissingElementException::class, + 'At most one may be specified.', ); $longTermAuthenticationRequestTokenUsed = LongTermAuthenticationRequestTokenUsed::getChildrenOfClass($xml); - Assert::count( + self::assertZeroOrOneOrExactlyOneWhenStrict( $longTermAuthenticationRequestTokenUsed, - 1, + $strictParsing, 'Exactly one must be specified.', - MissingElementException::class, + 'At most one may be specified.', ); $isFromNewLogin = IsFromNewLogin::getChildrenOfClass($xml); - Assert::count( + self::assertZeroOrOneOrExactlyOneWhenStrict( $isFromNewLogin, - 1, - 'Exactly least one must be specified.', - MissingElementException::class, + $strictParsing, + 'Exactly one must be specified.', + 'At most one may be specified.', ); return new static( - $authenticationDate[0], - $longTermAuthenticationRequestTokenUsed[0], - $isFromNewLogin[0], + $authenticationDate[0] ?? null, + $longTermAuthenticationRequestTokenUsed[0] ?? null, + $isFromNewLogin[0] ?? null, self::getChildElementsFromXML($xml), ); } @@ -140,9 +149,9 @@ public function toXML(?DOMElement $parent = null): DOMElement { $e = $this->instantiateParentElement($parent); - $this->getAuthenticationDate()->toXML($e); - $this->getLongTermAuthenticationRequestTokenUsed()->toXML($e); - $this->getIsFromNewLogin()->toXML($e); + $this->getAuthenticationDate()?->toXML($e); + $this->getLongTermAuthenticationRequestTokenUsed()?->toXML($e); + $this->getIsFromNewLogin()?->toXML($e); /** @psalm-var \SimpleSAML\XML\SerializableElementInterface $elt */ foreach ($this->elements as $elt) { @@ -153,4 +162,34 @@ public function toXML(?DOMElement $parent = null): DOMElement return $e; } + + + /** + * Assert that an array contains exactly one element when strict parsing is enabled, + * or zero or one element when strict parsing is disabled. + * + * @param array $elements The array to check + * @param bool $strict Whether strict parsing is enabled + * @param string $exactlyOneMessage The message to show when exactly one element is required + * @param string $atMostOneMessage The message to show when at most one element is allowed + * @return void + * + * @throws \SimpleSAML\XMLSchema\Exception\MissingElementException + * if the array contains more than one element, or if strict parsing is enabled and the array is empty + */ + private static function assertZeroOrOneOrExactlyOneWhenStrict( + array $elements, + bool $strict, + string $exactlyOneMessage, + string $atMostOneMessage, + ): void { + if ($strict) { + Assert::count($elements, 1, $exactlyOneMessage, MissingElementException::class); + return; + } + + if (count($elements) > 1) { + throw new MissingElementException($atMostOneMessage); + } + } } diff --git a/tests/CAS/XML/AttributesTest.php b/tests/CAS/XML/AttributesTest.php index accb868..f2c2984 100644 --- a/tests/CAS/XML/AttributesTest.php +++ b/tests/CAS/XML/AttributesTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\CAS\Test\XML; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use SimpleSAML\CAS\Utils\XPath; use SimpleSAML\CAS\XML\AbstractCasElement; @@ -15,6 +16,7 @@ use SimpleSAML\XML\Chunk; use SimpleSAML\XML\DOMDocumentFactory; use SimpleSAML\XML\TestUtils\SerializableElementTestTrait; +use SimpleSAML\XMLSchema\Exception\MissingElementException; use SimpleSAML\XMLSchema\Type\BooleanValue; use SimpleSAML\XMLSchema\Type\DateTimeValue; @@ -108,4 +110,84 @@ public function testMarshallingElementOrdering(): void $this->assertEquals('cas:longTermAuthenticationRequestTokenUsed', $attributesElements[0]->tagName); $this->assertEquals('cas:isFromNewLogin', $attributesElements[1]->tagName); } + + + /** + * @return array + */ + public static function slateResponseStrictnessProvider(): array + { + return [ + 'lenient parsing (strict=false) succeeds for Slate response' => [ + 'lenient', + false, + true, + ], + 'strict parsing (strict=true) fails for Slate response' => [ + 'strict', + true, + false, + ], + ]; + } + + + /** + * Ensure that parsing of the Slate CAS element via Attributes::fromXML + * succeeds when strict parsing is disabled and fails when strict parsing is enabled. + * + * In the Slate example, only contains firstname/lastname/email and + * is missing the CAS‑required: + * - cas:authenticationDate + * - cas:longTermAuthenticationRequestTokenUsed + * - cas:isFromNewLogin + * + * With strict=false => these are optional and parsing must succeed. + * With strict=true => they are required and parsing must fail. + * + * @param string $description Human-readable variant description + * @param bool $strictParsing Whether to enable strict parsing + * @param bool $shouldSucceed Whether parsing is expected to succeed + */ + #[DataProvider('slateResponseStrictnessProvider')] + public function testSlateAttributesParsingStrictness( + string $description, + bool $strictParsing, + bool $shouldSucceed, + ): void { + $doc = DOMDocumentFactory::fromFile( + dirname(__FILE__, 3) . '/resources/xml/cas-slate-success-response.xml', + ); + $root = $doc->documentElement; + $this->assertInstanceOf(\DOMElement::class, $root, 'Loaded Slate XML does not have a document element'); + + /** @var \DOMElement|null $attributesEl */ + $attributesEl = $doc->getElementsByTagNameNS('http://www.yale.edu/tp/cas', 'attributes')->item(0); + $this->assertInstanceOf(\DOMElement::class, $attributesEl, 'Slate XML must contain a cas:attributes element'); + + if (!$shouldSucceed) { + $this->expectException(MissingElementException::class); + $this->expectExceptionMessageMatches( + '/authenticationDate|longTermAuthenticationRequestTokenUsed|isFromNewLogin/i', + ); + } + + $attributes = Attributes::fromXML($attributesEl, $strictParsing); + + if ($shouldSucceed) { + // In lenient mode, we accept missing CAS attributes; they should be null. + $this->assertNull( + $attributes->getAuthenticationDate(), + sprintf('Expected null authenticationDate for "%s" case', $description), + ); + $this->assertNull( + $attributes->getLongTermAuthenticationRequestTokenUsed(), + sprintf('Expected null longTermAuthenticationRequestTokenUsed for "%s" case', $description), + ); + $this->assertNull( + $attributes->getIsFromNewLogin(), + sprintf('Expected null isFromNewLogin for "%s" case', $description), + ); + } + } } diff --git a/tests/CAS/XML/AuthenticationSuccessTest.php b/tests/CAS/XML/AuthenticationSuccessTest.php index a49d493..04b1444 100644 --- a/tests/CAS/XML/AuthenticationSuccessTest.php +++ b/tests/CAS/XML/AuthenticationSuccessTest.php @@ -158,4 +158,92 @@ public function testMarshallingElementOrdering(): void $this->assertEquals('cas:proxyGrantingTicket', $authenticationSuccessElements[1]->tagName); $this->assertEquals('cas:proxies', $authenticationSuccessElements[2]->tagName); } + + + /** + * Ensure that AuthenticationSuccess::fromXML can parse a Slate-style CAS response + * where: + * - cas:authenticationSuccess has top-level vendor elements (slate:person/round/ref) + * - cas:attributes is missing CAS core fields (authenticationDate, longTerm..., isFromNewLogin) + * + * Parsing must succeed in lenient mode (default strictness). + */ + public function testParsesSlateAuthenticationSuccessLenient(): void + { + $doc = DOMDocumentFactory::fromFile( + dirname(__FILE__, 3) . '/resources/xml/cas-slate-success-response.xml', + ); + + /** @var \DOMElement|null $authnEl */ + $authnEl = $doc->getElementsByTagNameNS('http://www.yale.edu/tp/cas', 'authenticationSuccess')->item(0); + $this->assertInstanceOf( + \DOMElement::class, + $authnEl, + 'Slate XML must contain cas:authenticationSuccess', + ); + + // Default parsing is lenient (no strict flag), so missing CAS attributes inside cas:attributes + // should not make parsing fail. + $authn = AuthenticationSuccess::fromXML($authnEl); + + // User must be parsed + $this->assertSame( + 'example-user@technolutions.com', + (string) $authn->getUser()->getContent(), + 'Unexpected cas:user value from Slate response', + ); + + // cas:attributes element exists + $attrs = $authn->getAttributes(); + + // Assert core CAS attributes are NOT present (all must be null) + $this->assertNull( + $attrs->getAuthenticationDate(), + 'Slate AuthenticationSuccess must not contain cas:authenticationDate', + ); + $this->assertNull( + $attrs->getLongTermAuthenticationRequestTokenUsed(), + 'Slate AuthenticationSuccess must not contain cas:longTermAuthenticationRequestTokenUsed', + ); + $this->assertNull( + $attrs->getIsFromNewLogin(), + 'Slate AuthenticationSuccess must not contain cas:isFromNewLogin', + ); + + // Ensure extension attributes (firstname/lastname/email) were preserved as Chunks. + $chunks = $attrs->getElements(); + $this->assertNotEmpty($chunks, 'Expected at least one attribute Chunk from Slate response'); + + $names = []; + foreach ($chunks as $chunk) { + if ($chunk instanceof Chunk) { + $names[] = $chunk->getLocalName(); + } + } + + $this->assertContains('firstname', $names); + $this->assertContains('lastname', $names); + $this->assertContains('email', $names); + + // Top-level Slate elements under cas:authenticationSuccess should be preserved as metadata + $metadata = $authn->getAuthenticationSuccessMetadata(); + $this->assertCount(3, $metadata, 'Expected three top-level Slate metadata elements'); + + $seen = []; + /** @var \DOMElement $el */ + foreach ($metadata as $el) { + $this->assertSame('http://technolutions.com/slate', $el->namespaceURI); + + if ($el->localName === null) { + // Skip elements without a local name; they cannot be used as array keys + continue; + } + + $seen[$el->localName] = trim($el->textContent ?? ''); + } + + $this->assertSame('345d2e1b-65de-419c-96ce-e1866d4c57cd', $seen['person'] ?? null); + $this->assertSame('Regular Decision', $seen['round'] ?? null); + $this->assertSame('774482874', $seen['ref'] ?? null); + } } diff --git a/tests/resources/xml/cas-slate-success-response.xml b/tests/resources/xml/cas-slate-success-response.xml new file mode 100644 index 0000000..0626647 --- /dev/null +++ b/tests/resources/xml/cas-slate-success-response.xml @@ -0,0 +1,15 @@ + + + + example-user@technolutions.com + 345d2e1b-65de-419c-96ce-e1866d4c57cd + Regular Decision + 774482874 + + Example + User + example-user@technolutions.com + + + \ No newline at end of file