Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 68 additions & 29 deletions src/CAS/XML/Attributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,43 +39,43 @@ 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);
}


/**
* @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;
}
Expand All @@ -84,47 +84,56 @@ 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, <cas:attributes> 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
* if the qualified name of the supplied element is wrong
* @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 <cas:authenticationDate> must be specified.',
MissingElementException::class,
'At most one <cas:authenticationDate> may be specified.',
);

$longTermAuthenticationRequestTokenUsed = LongTermAuthenticationRequestTokenUsed::getChildrenOfClass($xml);
Assert::count(
self::assertZeroOrOneOrExactlyOneWhenStrict(
$longTermAuthenticationRequestTokenUsed,
1,
$strictParsing,
'Exactly one <cas:longTermAuthenticationRequestTokenUsed> must be specified.',
MissingElementException::class,
'At most one <cas:longTermAuthenticationRequestTokenUsed> may be specified.',
);

$isFromNewLogin = IsFromNewLogin::getChildrenOfClass($xml);
Assert::count(
self::assertZeroOrOneOrExactlyOneWhenStrict(
$isFromNewLogin,
1,
'Exactly least one <cas:isFromNewLogin> must be specified.',
MissingElementException::class,
$strictParsing,
'Exactly one <cas:isFromNewLogin> must be specified.',
'At most one <cas:isFromNewLogin> may be specified.',
);

return new static(
$authenticationDate[0],
$longTermAuthenticationRequestTokenUsed[0],
$isFromNewLogin[0],
$authenticationDate[0] ?? null,
$longTermAuthenticationRequestTokenUsed[0] ?? null,
$isFromNewLogin[0] ?? null,
self::getChildElementsFromXML($xml),
);
}
Expand All @@ -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) {
Expand All @@ -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<mixed> $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);
}
}
}
82 changes: 82 additions & 0 deletions tests/CAS/XML/AttributesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -108,4 +110,84 @@ public function testMarshallingElementOrdering(): void
$this->assertEquals('cas:longTermAuthenticationRequestTokenUsed', $attributesElements[0]->tagName);
$this->assertEquals('cas:isFromNewLogin', $attributesElements[1]->tagName);
}


/**
* @return array<string, array{0:string,1:bool,2:bool}>
*/
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 <cas:attributes> element via Attributes::fromXML
* succeeds when strict parsing is disabled and fails when strict parsing is enabled.
*
* In the Slate example, <cas:attributes> 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),
);
}
}
}
88 changes: 88 additions & 0 deletions tests/CAS/XML/AuthenticationSuccessTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
15 changes: 15 additions & 0 deletions tests/resources/xml/cas-slate-success-response.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!-- Example CAS Response -->
<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas"
xmlns:slate="http://technolutions.com/slate">
<cas:authenticationSuccess>
<cas:user>example-user@technolutions.com</cas:user>
<slate:person>345d2e1b-65de-419c-96ce-e1866d4c57cd</slate:person>
<slate:round>Regular Decision</slate:round>
<slate:ref>774482874</slate:ref>
<cas:attributes>
<cas:firstname>Example</cas:firstname>
<cas:lastname>User</cas:lastname>
<cas:email>example-user@technolutions.com</cas:email>
</cas:attributes>
</cas:authenticationSuccess>
</cas:serviceResponse>