diff --git a/modules/saml/docs/authproc_pairwiseid.md b/modules/saml/docs/authproc_pairwiseid.md new file mode 100644 index 0000000000000000000000000000000000000000..772b6a5ae397b56d7fb16841d89e02cb991b1b55 --- /dev/null +++ b/modules/saml/docs/authproc_pairwiseid.md @@ -0,0 +1,28 @@ +`subjectidattrs:PairwiseID` +=================== + +Filter to insert a pairwise-id that complies with the +[SAML V2.0 Subject Identifier Attributes Profile][specification]. + +[specification]: http://docs.oasis-open.org/security/saml-subject-id-attr/v1.0/saml-subject-id-attr-v1.0.pdf + +This filter will take an attribute and a scope as input and transforms this +into a anonymized and scoped identifier that is globally unique for a given +user & service provider combination. + +Note: +Since the subject-id is specified as single-value attribute, only the first +value of `identifyingAttribute` and `scopeAttribute` are considered. + +Examples +-------- + +```php + 'authproc' => [ + 50 => [ + 'class' => 'subjectidattrs:PairwiseID', + 'identifyingAttribute' => 'uid', + 'scopeAttribute' => 'scope', + ], + ], +``` diff --git a/modules/saml/docs/authproc_subjectid.md b/modules/saml/docs/authproc_subjectid.md new file mode 100644 index 0000000000000000000000000000000000000000..1752ddbfccc22df54d0d4977dac6ba81afd39ee0 --- /dev/null +++ b/modules/saml/docs/authproc_subjectid.md @@ -0,0 +1,31 @@ +`subjectidattrs:SubjectID` +=================== + +Filter to insert a subject-id that complies with the +[SAML V2.0 Subject Identifier Attributes Profile][specification]. + +[specification]: http://docs.oasis-open.org/security/saml-subject-id-attr/v1.0/saml-subject-id-attr-v1.0.pdf + +This filter will take an attribute and a scope as input and transforms this +into a scoped identifier that is globally unique for a given user. + +**Note** +If privacy is of your concern, you may want to use the PairwiseID-filter +instead. + +**Note** +Since the subject-id is specified as single-value attribute, only the first +value of `identifyingAttribute` and `scopeAttribute` are considered. + +Examples +-------- + +```php + 'authproc' => [ + 50 => [ + 'class' => 'subjectidattrs:SubjectID', + 'identifyingAttribute' => 'uid', + 'scopeAttribute' => 'scope', + ], + ], +``` diff --git a/modules/saml/src/Auth/Process/PairwiseID.php b/modules/saml/src/Auth/Process/PairwiseID.php new file mode 100644 index 0000000000000000000000000000000000000000..db5d5673cab4b51bd96841228b8eeecee0bdf849 --- /dev/null +++ b/modules/saml/src/Auth/Process/PairwiseID.php @@ -0,0 +1,105 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\saml\Auth\Process; + +use SAML2\Constants; +use SimpleSAML\Assert\Assert; +use SimpleSAML\{Auth, Utils}; + +/** + * Filter to generate the Pairwise ID attribute. + * + * See: http://docs.oasis-open.org/security/saml-subject-id-attr/v1.0/csprd01/saml-subject-id-attr-v1.0-csprd01.html + * + * By default, this filter will generate the ID based on the UserID of the current user. + * This is generated from the attribute configured in 'identifyingAttribute' in the + * authproc-configuration. + * + * NOTE: since the subject-id is specified as single-value attribute, only the first value of `identifyingAttribute` + * and `scopeAttribute` are considered. + * + * Example - generate from attribute: + * <code> + * 'authproc' => [ + * 50 => [ + * 'saml:PairwiseID', + * 'identifyingAttribute' => 'uid', + * 'scopeAttribute' => 'example.org', + * ] + * ] + * </code> + * + * @package SimpleSAMLphp + */ +class PairwiseID extends SubjectID +{ + /** + * The name for this class + */ + public const NAME = 'PairwiseID'; + + /** + * @var \SimpleSAML\Utils\Config + */ + protected Utils\Config $configUtils; + + + /** + * Initialize this filter. + * + * @param array &$config Configuration information about this filter. + * @param mixed $reserved For future use. + */ + public function __construct(array &$config, $reserved) + { + parent::__construct($config, $reserved); + + $this->configUtils = new Utils\Config(); + } + + + /** + * Apply filter to add the Pairwise ID. + * + * @param array &$state The current state. + */ + public function process(array &$state): void + { + $userID = $this->getIdentifyingAttribute($state); + $scope = $this->getScopeAttribute($state); + + if ($scope === null || $userID === null) { + // Attributes missing, precondition not met + return; + } + + if (!empty($state['saml:RequesterID'])) { + // Proxied request - use actual SP entity ID + $sp_entityid = $state['saml:RequesterID'][0]; + } else { + $sp_entityid = $state['core:SP']; + } + + // Calculate hash + $salt = $this->configUtils->getSecretSalt(); + $hash = hash_hmac('sha256', $userID . '|' . $sp_entityid, $salt, false); + + $value = $hash . '@' . strtolower($scope); + $this->validateGeneratedIdentifier($value); + + $state['Attributes'][Constants::ATTR_PAIRWISE_ID] = [$value]; + } + + + /** + * Inject the \SimpleSAML\Utils\Config dependency. + * + * @param \SimpleSAML\Utils\Config $configUtils + */ + public function setConfigUtils(Utils\Config $configUtils): void + { + $this->configUtils = $configUtils; + } +} diff --git a/modules/saml/src/Auth/Process/SubjectID.php b/modules/saml/src/Auth/Process/SubjectID.php new file mode 100644 index 0000000000000000000000000000000000000000..9f984e97a9dff8a1a098b81165158d2f275d8eb0 --- /dev/null +++ b/modules/saml/src/Auth/Process/SubjectID.php @@ -0,0 +1,232 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\saml\Auth\Process; + +use SAML2\Constants; +use SAML2\Exception\ProtocolViolationException; +use SimpleSAML\Assert\Assert; +use SimpleSAML\{Auth, Logger}; + +/** + * Filter to generate the subject ID attribute. + * + * See: http://docs.oasis-open.org/security/saml-subject-id-attr/v1.0/csprd01/saml-subject-id-attr-v1.0-csprd01.html + * + * By default, this filter will generate the ID based on the UserID of the current user. + * This is generated from the attribute configured in 'identifyingAttribute' in the + * authproc-configuration. + * + * NOTE: since the subject-id is specified as single-value attribute, only the first value of `identifyingAttribute` + * and `scopeAttribute` are considered. + * + * Example - generate from attribute: + * <code> + * 'authproc' => [ + * 50 => [ + * 'saml:SubjectID', + * 'identifyingAttribute' => 'uid', + * 'scopeAttribute' => 'scope', + * ] + * ] + * </code> + * + * @package SimpleSAMLphp + */ +class SubjectID extends Auth\ProcessingFilter +{ + /** + * The name for this class + */ + public const NAME = 'SubjectID'; + + /** + * The regular expression to match the scope + * + * @var string + */ + public const SCOPE_PATTERN = '/^[a-z0-9][a-z0-9.-]{0,126}$/i'; + + /** + * The regular expression to match the specifications + * + * @var string + */ + public const SPEC_PATTERN = '/^[a-z0-9][a-z0-9=-]{0,126}@[a-z0-9][a-z0-9.-]{0,126}$/i'; + + /** + * The regular expression to match worrisome identifiers that need to raise a warning + * + * @var string + */ + public const WARN_PATTERN = '/^[a-z0-9][a-z0-9=-]{3,}@[a-z0-9][a-z0-9.-]+\.[a-z]{2,}$/i'; + + /** + * The attribute we should generate the subject id from. + * + * @var string + */ + protected string $identifyingAttribute; + + /** + * The attribute we should use for the scope of the subject id. + * + * @var string + */ + protected string $scopeAttribute; + + /** + * @var \SimpleSAML\Logger|string + * @psalm-var \SimpleSAML\Logger|class-string + */ + protected $logger = Logger::class; + + + /** + * Initialize this filter. + * + * @param array &$config Configuration information about this filter. + * @param mixed $reserved For future use. + */ + public function __construct(array &$config, $reserved) + { + parent::__construct($config, $reserved); + + Assert::keyExists($config, 'identifyingAttribute', "Missing mandatory 'identifyingAttribute' config setting."); + Assert::keyExists($config, 'scopeAttribute', "Missing mandatory 'scopeAttribute' config setting."); + Assert::stringNotEmpty($config['identifyingAttribute']); + Assert::stringNotEmpty($config['scopeAttribute']); + + $this->identifyingAttribute = $config['identifyingAttribute']; + $this->scopeAttribute = $config['scopeAttribute']; + } + + + /** + * Apply filter to add the subject ID. + * + * @param array &$state The current state. + */ + public function process(array &$state): void + { + $userID = $this->getIdentifyingAttribute($state); + $scope = $this->getScopeAttribute($state); + + if ($scope === null || $userID === null) { + // Attributes missing, precondition not met + return; + } + + $value = strtolower($userID . '@' . $scope); + $this->validateGeneratedIdentifier($value); + + $state['Attributes'][Constants::ATTR_SUBJECT_ID] = [$value]; + } + + + /** + * Retrieve the identifying attribute from the state and test it for erroneous conditions + * + * @param array $state + * @return string|null + * @throws \SimpleSAML\Assert\AssertionFailedException if the pre-conditions are not met + */ + protected function getIdentifyingAttribute(array $state): ?string + { + if ( + !array_key_exists('Attributes', $state) + || !array_key_exists($this->identifyingAttribute, $state['Attributes']) + ) { + $this->logger::warning( + sprintf( + "saml:" . static::NAME . ": Missing attribute '%s', which is needed to generate the ID.", + $this->identifyingAttribute + ) + ); + + return null; + } + + $userID = $state['Attributes'][$this->identifyingAttribute][0]; + Assert::stringNotEmpty($userID, 'saml:' . static::NAME . ': \'identifyingAttribute\' cannot be an empty string.'); + + return $userID; + } + + + /** + * Retrieve the scope attribute from the state and test it for erroneous conditions + * + * @param array $state + * @return string|null + * @throws \SimpleSAML\Assert\AssertionFailedException if the scope is an empty string + * @throws \SAML2\Exception\ProtocolViolationException if the pre-conditions are not met + */ + protected function getScopeAttribute(array $state): ?string + { + if (!array_key_exists('Attributes', $state) || !array_key_exists($this->scopeAttribute, $state['Attributes'])) { + $this->logger::warning( + sprintf( + "saml:" . static::NAME . ": Missing attribute '%s', which is needed to generate the ID.", + $this->scopeAttribute + ) + ); + + return null; + } + + $scope = $state['Attributes'][$this->scopeAttribute][0]; + Assert::stringNotEmpty($scope, 'saml:' . static::NAME . ': \'scopeAttribute\' cannot be an empty string.'); + + // If the value is scoped, extract the scope from it + if (strpos($scope, '@') !== false) { + $scope = explode('@', $scope, 2); + $scope = $scope[1]; + } + + Assert::regex( + $scope, + self::SCOPE_PATTERN, + 'saml:' . static::NAME . ': \'scopeAttribute\' contains illegal characters.', + ProtocolViolationException::class + ); + return $scope; + } + + + /** + * Test the generated identifier to ensure compliancy with the specifications. + * Log a warning when the generated value is considered to be weak + * + * @param string $value + * @return void + * @throws \SAML2\Exception\ProtocolViolationException if the post-conditions are not met + */ + protected function validateGeneratedIdentifier(string $value): void + { + Assert::regex( + $value, + self::SPEC_PATTERN, + 'saml:' . static::NAME . ': Generated ID \'' . $value . '\' contains illegal characters.', + ProtocolViolationException::class + ); + + if (preg_match(self::WARN_PATTERN, $value) === 0) { + $this->logger::warning( + 'saml:' . static::NAME . ': Generated ID \'' . $value . '\' can hardly be considered globally unique.' + ); + } + } + + + /** + * Inject the \SimpleSAML\Logger dependency. + * + * @param \SimpleSAML\Logger $logger + */ + public function setLogger(Logger $logger): void + { + $this->logger = $logger; + } +} diff --git a/tests/modules/saml/src/Auth/Process/PairwiseIDTest.php b/tests/modules/saml/src/Auth/Process/PairwiseIDTest.php new file mode 100644 index 0000000000000000000000000000000000000000..911c3311f40ea9fc7306dcb26ea6e48d0da5e9cc --- /dev/null +++ b/tests/modules/saml/src/Auth/Process/PairwiseIDTest.php @@ -0,0 +1,340 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\saml\Auth\Process; + +use PHPUnit\Framework\TestCase; +use RuntimeException; +use SAML2\Constants; +use SAML2\Exception\ProtocolViolationException; +use SimpleSAML\Assert\AssertionFailedException; +use SimpleSAML\{Configuration, Logger, Utils}; +use SimpleSAML\Module\saml\Auth\Process\PairwiseID; + +/** + * Test for the saml:PairwiseID filter. + * + * @covers \SimpleSAML\Module\saml\Auth\Process\PairwiseID + */ +class PairwiseIDTest extends TestCase +{ + /** @var \SimpleSAML\Configuration */ + protected Configuration $config; + + /** @var \SimpleSAML\Utils\Config */ + protected static Utils\Config $configUtils; + + /** @var \SimpleSAML\Logger */ + protected static Logger $logger; + + + /** + * Set up for each test. + */ + protected function setUp(): void + { + parent::setUp(); + + self::$configUtils = new class () extends Utils\Config { + public function getSecretSalt(): string + { + // stub + return 'secretsalt'; + } + }; + + self::$logger = new class () extends Logger { + public static function warning(string $string): void + { + // stub + throw new RuntimeException($string); + } + }; + } + + + /** + * Helper function to run the filter with a given configuration. + * + * @param array $config The filter configuration. + * @param array $request The request state. + * @return array The state array after processing. + */ + private static function processFilter(array $config, array $request): array + { + $filter = new PairwiseID($config, null); + $filter->setConfigUtils(self::$configUtils); + $filter->setLogger(self::$logger); + $filter->process($request); + return $request; + } + + + /** + * Test the most basic functionality + */ + public function testBasic(): void + { + $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope']; + $request = [ + 'Attributes' => ['uid' => ['u=se-r2'], 'scope' => ['ex-ample.org']], + 'core:SP' => 'urn:sp', + ]; + $result = self::processFilter($config, $request); + $attributes = $result['Attributes']; + $this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes); + $this->assertMatchesRegularExpression( + PairwiseID::SPEC_PATTERN, + $attributes[Constants::ATTR_PAIRWISE_ID][0] + ); + $this->assertEquals( + 'c22d58bebef42e50e203d0e932ae4a7f560a51d494266990a5b5c73f34b1854e@ex-ample.org', + $attributes[Constants::ATTR_PAIRWISE_ID][0] + ); + } + + + /** + * Test the most basic functionality, but with a scoped scope-attribute + */ + public function testBasicScopedScope(): void + { + $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope']; + $request = [ + 'Attributes' => ['uid' => ['u=se-r2'], 'scope' => ['u=se-r2@ex-ample.org']], + 'core:SP' => 'urn:sp', + ]; + $result = self::processFilter($config, $request); + $attributes = $result['Attributes']; + $this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes); + $this->assertMatchesRegularExpression( + PairwiseID::SPEC_PATTERN, + $attributes[Constants::ATTR_PAIRWISE_ID][0] + ); + $this->assertEquals( + 'c22d58bebef42e50e203d0e932ae4a7f560a51d494266990a5b5c73f34b1854e@ex-ample.org', + $attributes[Constants::ATTR_PAIRWISE_ID][0] + ); + } + + + /** + * Test the most basic functionality on proxied request + */ + public function testBasicProxiedRequest(): void + { + $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope']; + $request = [ + 'Attributes' => ['uid' => ['u=se-r2'], 'scope' => ['ex-ample.org']], + 'saml:RequesterID' => [0 => 'urn:sp'], + ]; + $result = self::processFilter($config, $request); + $attributes = $result['Attributes']; + $this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes); + $this->assertMatchesRegularExpression( + PairwiseID::SPEC_PATTERN, + $attributes[Constants::ATTR_PAIRWISE_ID][0] + ); + $this->assertEquals( + 'c22d58bebef42e50e203d0e932ae4a7f560a51d494266990a5b5c73f34b1854e@ex-ample.org', + $attributes[Constants::ATTR_PAIRWISE_ID][0] + ); + } + + + /** + * Test the proxied request with multiple hops + */ + public function testProxiedRequestMultipleHops(): void + { + $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope']; + $request = [ + 'Attributes' => ['uid' => ['u=se-r2'], 'scope' => ['ex-ample.org']], + 'saml:RequesterID' => [0 => 'urn:sp', 1 => 'urn:some:sp', 2 => 'urn:some:other:sp'], + ]; + $result = self::processFilter($config, $request); + $attributes = $result['Attributes']; + $this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes); + $this->assertMatchesRegularExpression( + PairwiseID::SPEC_PATTERN, + $attributes[Constants::ATTR_PAIRWISE_ID][0] + ); + $this->assertEquals( + 'c22d58bebef42e50e203d0e932ae4a7f560a51d494266990a5b5c73f34b1854e@ex-ample.org', + $attributes[Constants::ATTR_PAIRWISE_ID][0] + ); + } + + + /** + * Test that illegal characters in scope throws an exception. + */ + public function testScopeIllegalCharacterThrowsException(): void + { + $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope']; + $request = [ + 'Attributes' => ['uid' => ['user2'], 'scope' => ['ex%ample.org']], + 'core:SP' => 'urn:sp', + ]; + + $this->expectException(ProtocolViolationException::class); + self::processFilter($config, $request); + } + + + /** + * Test that generated ID's for the same user, but different SP's are NOT equal + */ + public function testUniqueIdentifierPerSPSameUser(): void + { + $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope']; + $request = [ + 'Attributes' => ['uid' => ['user1'], 'scope' => ['example.org']], + 'core:SP' => 'urn:sp', + ]; + + // Generate first ID + $result = self::processFilter($config, $request); + $attributes = $result['Attributes']; + $this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes); + $value1 = $attributes[Constants::ATTR_PAIRWISE_ID][0]; + + // Switch SP + $request['core:SP'] = 'urn:some:other:sp'; + + // Generate second ID + $result = self::processFilter($config, $request); + $attributes = $result['Attributes']; + $this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes); + $value2 = $attributes[Constants::ATTR_PAIRWISE_ID][0]; + + $this->assertNotSame($value1, $value2); + } + + + /** + * Test that generated ID's for different users, but the same SP's are NOT equal + */ + public function testUniqueIdentifierPerUserSameSP(): void + { + $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope']; + $request = [ + 'Attributes' => ['uid' => ['user1'], 'scope' => ['example.org']], + 'core:SP' => 'urn:sp', + ]; + + // Generate first ID + $result = self::processFilter($config, $request); + $attributes = $result['Attributes']; + $this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes); + $value1 = $attributes[Constants::ATTR_PAIRWISE_ID][0]; + + // Switch user + $request['Attributes']['uid'] = ['user2']; + + // Generate second ID + $result = self::processFilter($config, $request); + $attributes = $result['Attributes']; + $this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes); + $value2 = $attributes[Constants::ATTR_PAIRWISE_ID][0]; + + $this->assertNotSame($value1, $value2); + } + + + /** + * Test that generated ID's for the same user and same SP, but with a different salt are NOT equal + */ + public function testUniqueIdentifierDifferentSalts(): void + { + $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope']; + $request = [ + 'Attributes' => ['uid' => ['user1'], 'scope' => ['example.org']], + 'core:SP' => 'urn:sp', + ]; + + // Generate first ID + $result = self::processFilter($config, $request); + $attributes = $result['Attributes']; + $this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes); + $value1 = $attributes[Constants::ATTR_PAIRWISE_ID][0]; + + // Change the salt + self::$configUtils = new class () extends Utils\Config { + public function getSecretSalt(): string + { + // stub + return 'pepper'; + } + }; + + // Generate second ID + $result = self::processFilter($config, $request); + $attributes = $result['Attributes']; + $this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes); + $value2 = $attributes[Constants::ATTR_PAIRWISE_ID][0]; + + $this->assertNotSame($value1, $value2); + } + + + /** + * Test that generated ID's for the same user and same SP, but with a different scope are NOT equal + */ + public function testUniqueIdentifierDifferentScopes(): void + { + $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope']; + $request = [ + 'Attributes' => ['uid' => ['user1'], 'scope' => ['example.org']], + 'core:SP' => 'urn:sp', + ]; + + // Generate first ID + $result = self::processFilter($config, $request); + $attributes = $result['Attributes']; + $this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes); + $value1 = $attributes[Constants::ATTR_PAIRWISE_ID][0]; + + // Change the scope + $request['Attributes']['scope'] = ['example.edu']; + + // Generate second ID + $result = self::processFilter($config, $request); + $attributes = $result['Attributes']; + $this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes); + $value2 = $attributes[Constants::ATTR_PAIRWISE_ID][0]; + + $this->assertNotSame($value1, $value2); + + $this->assertMatchesRegularExpression( + '/@example.org$/i', + $value1 + ); + $this->assertMatchesRegularExpression( + '/@example.edu$/i', + $value2 + ); + } + + + /** + * Test that weak identifiers log a warning + */ + public function testWeakIdentifierLogsWarning(): void + { + $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope']; + $request = [ + 'Attributes' => ['uid' => ['a'], 'scope' => ['b']], + 'core:SP' => 'urn:sp', + ]; + + $expected = 'be511fc7f95e22816dbac21e3b70546660963b6e9b85f5a41d80bfc6baadd547@b'; + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'saml:PairwiseID: Generated ID \'' . $expected . '\' can hardly be considered globally unique.' + ); + + self::processFilter($config, $request); + } +} diff --git a/tests/modules/saml/src/Auth/Process/SubjectIDTest.php b/tests/modules/saml/src/Auth/Process/SubjectIDTest.php new file mode 100644 index 0000000000000000000000000000000000000000..2a515d6e2ffc00caab3a55f1457a0d653d7eef04 --- /dev/null +++ b/tests/modules/saml/src/Auth/Process/SubjectIDTest.php @@ -0,0 +1,230 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\saml\Auth\Process; + +use PHPUnit\Framework\TestCase; +use RuntimeException; +use SAML2\Constants; +use SAML2\Exception\ProtocolViolationException; +use SimpleSAML\Assert\AssertionFailedException; +use SimpleSAML\{Configuration, Logger, Utils}; +use SimpleSAML\Module\saml\Auth\Process\SubjectID; + +/** + * Test for the saml:SubjectID filter. + * + * @covers \SimpleSAML\Module\saml\Auth\Process\SubjectID + */ +class SubjectIDTest extends TestCase +{ + /** @var \SimpleSAML\Configuration */ + protected Configuration $config; + + /** @var \SimpleSAML\Logger */ + protected static Logger $logger; + + + /** + * Set up for each test. + */ + protected function setUp(): void + { + parent::setUp(); + + self::$logger = new class () extends Logger { + public static function warning(string $string): void + { + // stub + throw new RuntimeException($string); + } + }; + } + + + /** + * Helper function to run the filter with a given configuration. + * + * @param array $config The filter configuration. + * @param array $request The request state. + * @return array The state array after processing. + */ + private static function processFilter(array $config, array $request): array + { + $filter = new SubjectID($config, null); + $filter->setLogger(self::$logger); + $filter->process($request); + return $request; + } + + + /** + * Test the most basic functionality + */ + public function testBasic(): void + { + $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope']; + $request = [ + 'Attributes' => ['uid' => ['u=se-r2'], 'scope' => ['ex-ample.org']], + ]; + $result = self::processFilter($config, $request); + $attributes = $result['Attributes']; + $this->assertArrayHasKey(Constants::ATTR_SUBJECT_ID, $attributes); + $this->assertMatchesRegularExpression( + SubjectID::SPEC_PATTERN, + $attributes[Constants::ATTR_SUBJECT_ID][0] + ); + $this->assertEquals('u=se-r2@ex-ample.org', $attributes[Constants::ATTR_SUBJECT_ID][0]); + } + + + /** + * Test the most basic functionality, but with a scoped scope-attribute + */ + public function testScopedScope(): void + { + $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope']; + $request = [ + 'Attributes' => ['uid' => ['u=se-r2'], 'scope' => ['u=se-r2@ex-ample.org']], + ]; + $result = self::processFilter($config, $request); + $attributes = $result['Attributes']; + $this->assertArrayHasKey(Constants::ATTR_SUBJECT_ID, $attributes); + $this->assertMatchesRegularExpression( + SubjectID::SPEC_PATTERN, + $attributes[Constants::ATTR_SUBJECT_ID][0] + ); + $this->assertEquals('u=se-r2@ex-ample.org', $attributes[Constants::ATTR_SUBJECT_ID][0]); + } + + + /** + * Test that illegal characters in userID throws an exception. + */ + public function testUserIDIllegalCharacterThrowsException(): void + { + $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope']; + $request = [ + 'Attributes' => ['uid' => ['u=se+r2'], 'scope' => ['example.org']], + ]; + + $this->expectException(ProtocolViolationException::class); + self::processFilter($config, $request); + } + + + /** + * Test that illegal characters in scope throws an exception. + */ + public function testScopeIllegalCharacterThrowsException(): void + { + $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope']; + $request = [ + 'Attributes' => ['uid' => ['user2'], 'scope' => ['ex%ample.org']], + ]; + + $this->expectException(ProtocolViolationException::class); + self::processFilter($config, $request); + } + + + /** + * Test that generated ID's for different users, but the same SP's are NOT equal + */ + public function testUniqueIdentifierPerUserSameSP(): void + { + $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope']; + $request = [ + 'Attributes' => ['uid' => ['user1'], 'scope' => ['example.org']], + ]; + + // Generate first ID + $result = self::processFilter($config, $request); + $attributes = $result['Attributes']; + $this->assertArrayHasKey(Constants::ATTR_SUBJECT_ID, $attributes); + $value1 = $attributes[Constants::ATTR_SUBJECT_ID][0]; + + // Switch user + $request['Attributes']['uid'] = ['user2']; + + // Generate second ID + $result = self::processFilter($config, $request); + $attributes = $result['Attributes']; + $this->assertArrayHasKey(Constants::ATTR_SUBJECT_ID, $attributes); + $value2 = $attributes[Constants::ATTR_SUBJECT_ID][0]; + + $this->assertNotSame($value1, $value2); + } + + + /** + * Test that generated ID's for the same user and same SP, but with a different scope are NOT equal + */ + public function testUniqueIdentifierDifferentScopes(): void + { + $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope']; + $request = [ + 'Attributes' => ['uid' => ['user1'], 'scope' => ['example.org']], + ]; + + // Generate first ID + $result = self::processFilter($config, $request); + $attributes = $result['Attributes']; + $this->assertArrayHasKey(Constants::ATTR_SUBJECT_ID, $attributes); + $value1 = $attributes[Constants::ATTR_SUBJECT_ID][0]; + + // Change the scope + $request['Attributes']['scope'] = ['example.edu']; + + // Generate second ID + $result = self::processFilter($config, $request); + $attributes = $result['Attributes']; + $this->assertArrayHasKey(Constants::ATTR_SUBJECT_ID, $attributes); + $value2 = $attributes[Constants::ATTR_SUBJECT_ID][0]; + + $this->assertNotSame($value1, $value2); + + $this->assertMatchesRegularExpression( + '/@example.org$/i', + $value1 + ); + $this->assertMatchesRegularExpression( + '/@example.edu$/i', + $value2 + ); + } + + + /** + * Test that weak identifiers log a warning + */ + public function testWeakIdentifierLogsWarning(): void + { + $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope']; + $request = [ + 'Attributes' => ['uid' => ['a'], 'scope' => ['b']], + ]; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('saml:SubjectID: Generated ID \'a@b\' can hardly be considered globally unique.'); + + self::processFilter($config, $request); + } + + /** + * Test that weak identifiers log a warning: not an actual domain name + */ + public function testScopeNotADomainLogsWarning(): void + { + $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope']; + $request = [ + 'Attributes' => ['uid' => ['a1398u9u25'], 'scope' => ['example']], + ]; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('saml:SubjectID: Generated ID \'a1398u9u25@example\' can hardly be considered globally unique.'); + + self::processFilter($config, $request); + } +}