diff --git a/composer.json b/composer.json index 396c07d..13c454e 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,8 @@ }, "require": { "php": "^8.1", - "simplesamlphp/assert": "^1.8", + + "simplesamlphp/saml2": "~5.0.2", "simplesamlphp/simplesamlphp": "^2.4", "symfony/http-foundation": "^6.4" }, diff --git a/docs/authorize.md b/docs/authorize.md index 9a7bb67..a4a719a 100644 --- a/docs/authorize.md +++ b/docs/authorize.md @@ -156,3 +156,21 @@ Additionally, some helpful instructions are shown. ], ], ``` + +You can restrict an attribute allowance to a list of Service Providers. + +```php +'authproc.sp' => [ + 60 => array[ + 'class' => 'authorize:Authorize', + 'uid' => [ + '/.*@students.example.edu$/', + '/^(stu1|stu2|stu3)@example.edu$/', + 'spEntityIDs' => [ + 'https://example.com/sp1', + 'https://example.com/sp2' + ] + ] + ] +] +``` diff --git a/src/Auth/Process/Authorize.php b/src/Auth/Process/Authorize.php index e9366b2..3c7bb80 100644 --- a/src/Auth/Process/Authorize.php +++ b/src/Auth/Process/Authorize.php @@ -4,10 +4,10 @@ namespace SimpleSAML\Module\authorize\Auth\Process; -use Exception; -use SimpleSAML\Assert\Assert; use SimpleSAML\Auth; +use SimpleSAML\Error; use SimpleSAML\Module; +use SimpleSAML\SAML2\Assert\Assert; use SimpleSAML\Utils; use function array_diff; @@ -61,6 +61,7 @@ class Authorize extends Auth\ProcessingFilter /** * Array of valid users. Each element is a regular expression. You should * use \ to escape special chars, like '.' etc. + * can also contain 'spEntityIDs' arrays to restrict rules to specific SPs. * * @var array */ @@ -131,15 +132,32 @@ public function __construct(array $config, $reserved) $arrayUtils = new Utils\Arrays(); $values = $arrayUtils->arrayize($values); } elseif (!is_array($values)) { - throw new Exception(sprintf( + throw new Error\Exception(sprintf( 'Filter Authorize: Attribute values is neither string nor array: %s', var_export($attribute, true), )); } + // Extract spEntityIDs if present + $spEntityIDs = null; + if (isset($values['spEntityIDs'])) { + Assert::isArray( + $values['spEntityIDs'], + sprintf( + 'Filter Authorize: spEntityIDs must be an array for attribute: %s', + var_export($attribute, true), + ), + Error\Exception::class, + ); + Assert::allValidEntityID($values['spEntityIDs']); + + $spEntityIDs = $values['spEntityIDs']; + unset($values['spEntityIDs']); + } + foreach ($values as $value) { if (!is_string($value)) { - throw new Exception(sprintf( + throw new Error\Exception(sprintf( 'Filter Authorize: Each value should be a string for attribute: %s value: %s config: %s', var_export($attribute, true), var_export($value, true), @@ -147,7 +165,11 @@ public function __construct(array $config, $reserved) )); } } - $this->valid_attribute_values[$attribute] = $values; + + $this->valid_attribute_values[$attribute] = [ + 'values' => $values, + 'spEntityIDs' => $spEntityIDs, + ]; } } @@ -171,9 +193,27 @@ public function process(array &$state): void } $state['authprocAuthorize_errorURL'] = $this->errorURL; $state['authprocAuthorize_allow_reauthentication'] = $this->allow_reauthentication; + // Get current SP EntityID from state + $currentSpEntityId = null; + if (isset($state['saml:sp:State']['core:SP'])) { + $currentSpEntityId = $state['saml:sp:State']['core:SP']; + } elseif (isset($state['Destination']['entityid'])) { + $currentSpEntityId = $state['Destination']['entityid']; + } + $arrayUtils = new Utils\Arrays(); - foreach ($this->valid_attribute_values as $name => $patterns) { + foreach ($this->valid_attribute_values as $name => $ruleConfig) { if (array_key_exists($name, $attributes)) { + $patterns = $ruleConfig['values']; + $spEntityIDs = $ruleConfig['spEntityIDs']; + + // If spEntityIDs is specified, check if current SP is in the list + if ($spEntityIDs !== null) { + if ($currentSpEntityId === null || !in_array($currentSpEntityId, $spEntityIDs, true)) { + continue; // Skip this rule if SP is not specified or not in allowed list + } + } + foreach ($patterns as $pattern) { $values = $arrayUtils->arrayize($attributes[$name]); foreach ($values as $value) { diff --git a/tests/src/Auth/Process/AuthorizeTest.php b/tests/src/Auth/Process/AuthorizeTest.php index a5e4755..27cd27f 100644 --- a/tests/src/Auth/Process/AuthorizeTest.php +++ b/tests/src/Auth/Process/AuthorizeTest.php @@ -240,4 +240,123 @@ public static function showUserAttributeScenarioProvider(): array [['uid' => 'stu3@example.edu', 'mail' => 'user@example.edu'], false, true, 'user@example.edu'], ]; } + + /** + * Test SP restriction functionality + * + * @param array $userAttributes The attributes to test + * @param string|null $spEntityId The SP Entity ID in the state + * @param bool $isAuthorized Should the user be authorized + */ + #[DataProvider('spRestrictionScenarioProvider')] + public function testSpRestriction(array $userAttributes, ?string $spEntityId, bool $isAuthorized): void + { + $attributeUtils = new Utils\Attributes(); + $userAttributes = $attributeUtils->normalizeAttributesArray($userAttributes); + $config = [ + 'uid' => [ + '/.*@example.com$/', + 'spEntityIDs' => [ + 'https://sp1.example.com', + 'https://sp2.example.com', + ], + ], + 'group' => [ + '/^admins$/', + 'spEntityIDs' => [ + 'https://admin.example.com', + ], + ], + ]; + + $state = ['Attributes' => $userAttributes]; + if ($spEntityId !== null) { + $state['saml:sp:State']['core:SP'] = $spEntityId; + } + + $resultState = $this->processFilter($config, $state); + $resultAuthorized = isset($resultState['NOT_AUTHORIZED']) ? false : true; + $this->assertEquals($isAuthorized, $resultAuthorized); + } + + /** + * @return array + */ + public static function spRestrictionScenarioProvider(): array + { + return [ + // Should be allowed - matching attribute and SP + [['uid' => 'user@example.com'], 'https://sp1.example.com', true], + [['uid' => 'user@example.com'], 'https://sp2.example.com', true], + [['group' => 'admins'], 'https://admin.example.com', true], + + // Should be denied - matching attribute but wrong SP + [['uid' => 'user@example.com'], 'https://wrong.example.com', false], + [['group' => 'admins'], 'https://sp1.example.com', false], + + // Should be denied - no SP specified but attribute would match + [['uid' => 'user@example.com'], null, false], + [['group' => 'admins'], null, false], + + // Should be denied - wrong attribute regardless of SP + [['uid' => 'user@wrong.com'], 'https://sp1.example.com', false], + [['group' => 'users'], 'https://admin.example.com', false], + ]; + } + + /** + * Test mixed SP and non-SP rules + * + * @param array $userAttributes The attributes to test + * @param string|null $spEntityId The SP Entity ID in the state + * @param bool $isAuthorized Should the user be authorized + */ + #[DataProvider('mixedRulesScenarioProvider')] + public function testMixedSpAndNonSpRules(array $userAttributes, ?string $spEntityId, bool $isAuthorized): void + { + $attributeUtils = new Utils\Attributes(); + $userAttributes = $attributeUtils->normalizeAttributesArray($userAttributes); + $config = [ + // Rule with SP restriction + 'uid' => [ + '/.*@restricted.com$/', + 'spEntityIDs' => ['https://restricted.example.com'], + ], + // Rule without SP restriction (should work for all SPs) + 'role' => [ + '/^admin$/', + '/^superuser$/', + ], + ]; + + $state = ['Attributes' => $userAttributes]; + if ($spEntityId !== null) { + $state['saml:sp:State']['core:SP'] = $spEntityId; + } + + $resultState = $this->processFilter($config, $state); + $resultAuthorized = isset($resultState['NOT_AUTHORIZED']) ? false : true; + $this->assertEquals($isAuthorized, $resultAuthorized); + } + + /** + * @return array + */ + public static function mixedRulesScenarioProvider(): array + { + return [ + // Should be allowed - role rule matches (no SP restriction) + [['role' => 'admin'], 'https://any.example.com', true], + [['role' => 'superuser'], null, true], + + // Should be allowed - uid rule matches and SP is correct + [['uid' => 'user@restricted.com'], 'https://restricted.example.com', true], + + // Should be denied - uid rule matches but SP is wrong + [['uid' => 'user@restricted.com'], 'https://other.example.com', false], + + // Should be denied - no matching rules + [['uid' => 'user@other.com', 'role' => 'user'], 'https://any.example.com', false], + ]; + } }