Skip to content
Merged
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
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
18 changes: 18 additions & 0 deletions docs/authorize.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
]
]
]
]
```
52 changes: 46 additions & 6 deletions src/Auth/Process/Authorize.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<mixed>
*/
Expand Down Expand Up @@ -131,23 +132,44 @@ 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),
var_export($config, true),
));
}
}
$this->valid_attribute_values[$attribute] = $values;

$this->valid_attribute_values[$attribute] = [
'values' => $values,
'spEntityIDs' => $spEntityIDs,
];
}
}

Expand All @@ -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) {
Expand Down
119 changes: 119 additions & 0 deletions tests/src/Auth/Process/AuthorizeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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],
];
}
}
Loading