Skip to content

Commit 3669412

Browse files
committed
Add authproc-filter to manipulate the Assertion's Issuer (#2346)
1 parent 4bf8768 commit 3669412

File tree

3 files changed

+272
-0
lines changed

3 files changed

+272
-0
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
`saml:ScopedIssuer`
2+
===================
3+
4+
Filter to insert a dynamic saml:Issuer based on a scoped attribute.
5+
This is a requirement when serving multiple domains from one EntraID tenant.
6+
See: [How to connect multiple domains for federation][specification].
7+
8+
[specification]: https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/how-to-connect-install-multiple-domains#multiple-top-level-domain-support
9+
10+
This filter will take an attribute and a pattern as input and transforms this
11+
into a scoped saml:Issuer that is used in the SAML assertion.
12+
13+
Only the first value of `scopeAttribute` is considered.
14+
15+
Examples
16+
--------
17+
18+
```php
19+
'authproc' => [
20+
50 => [
21+
'class' => 'saml:ScopedIssuer',
22+
'scopeAttribute' => 'userPrincipalName',
23+
'pattern' => 'https://%1$s/issuer',
24+
],
25+
],
26+
```
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SimpleSAML\Module\saml\Auth\Process;
6+
7+
use SAML2\Exception\ProtocolViolationException;
8+
use SimpleSAML\{Auth, Utils};
9+
use SimpleSAML\Assert\Assert;
10+
11+
use function array_key_exists;
12+
use function explode;
13+
use function strpos;
14+
use function sprintf;
15+
16+
/**
17+
* Filter to generate a saml:issuer dynamically based on an input attribute.
18+
*
19+
* See: https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/how-to-connect-install-multiple-domains#multiple-top-level-domain-support
20+
*
21+
* By default, this filter will generate the saml:Issuer based on the userPrincipalName of the current user.
22+
* This is generated from the attribute configured in 'scopedAttribute' in the
23+
* authproc-configuration.
24+
*
25+
* NOTE: since the userPrincipalName is specified as single-value attribute, only the first value
26+
* of `scopedAttribute` is considered.
27+
*
28+
* Example - generate from attribute:
29+
* <code>
30+
* 'authproc' => [
31+
* 50 => [
32+
* 'saml:ScopedIssuer',
33+
* 'pattern' => 'https://%1$s/issuer',
34+
* 'scopedAttribute' => 'userPrincipalName',
35+
* ]
36+
* ]
37+
* </code>
38+
*
39+
* @package SimpleSAMLphp
40+
*/
41+
class ScopedIssuer extends Auth\ProcessingFilter
42+
{
43+
/**
44+
* The regular expression to match the scope
45+
*
46+
* @var string
47+
*/
48+
public const SCOPE_PATTERN = '/^[a-z0-9][a-z0-9.-]{0,126}$/Di';
49+
50+
/**
51+
* The attribute we should use for the scope of the saml:Issuer.
52+
*
53+
* @var string
54+
*/
55+
protected string $scopedAttribute;
56+
57+
/**
58+
* The pattern to use for the new saml:Issuer.
59+
*
60+
* @var string
61+
*/
62+
protected string $pattern;
63+
64+
65+
/**
66+
* Initialize this filter.
67+
*
68+
* @param array &$config Configuration information about this filter.
69+
* @param mixed $reserved For future use.
70+
*/
71+
public function __construct(array &$config, $reserved)
72+
{
73+
parent::__construct($config, $reserved);
74+
75+
Assert::keyExists($config, 'scopedAttribute', "Missing mandatory 'scopedAttribute' config setting.");
76+
Assert::stringNotEmpty($config['scopedAttribute']);
77+
78+
Assert::keyExists($config, 'pattern', "Missing mandatory 'pattern' config setting.");
79+
Assert::stringNotEmpty($config['pattern']);
80+
81+
$this->scopedAttribute = $config['scopedAttribute'];
82+
$this->pattern = $config['pattern'];
83+
}
84+
85+
86+
/**
87+
* Apply filter to dynamically set the saml:Issuer.
88+
*
89+
* @param array &$state The current state.
90+
*/
91+
public function process(array &$state): void
92+
{
93+
$scope = $this->getScopedAttribute($state);
94+
if ($scope === null) {
95+
// Attribute missing, precondition not met
96+
return;
97+
}
98+
99+
$value = sprintf($this->pattern, $scope);
100+
101+
// @todo: Replace the three asserts underneath with Assert::validEntityID in saml2v5
102+
Assert::validURI(
103+
$value,
104+
sprintf("saml:ScopedIssuer: Generated saml:Issuer '%s' contains illegal characters.", $value),
105+
ProtocolViolationException::class,
106+
);
107+
Assert::notWhitespaceOnly(
108+
$value,
109+
$message ?: '%s is not a SAML2-compliant URI',
110+
ProtocolViolationException::class,
111+
);
112+
// If it doesn't have a scheme, it's not an absolute URI
113+
Assert::regex(
114+
$value,
115+
'/^([a-z][a-z0-9\+\-\.]+[:])/i',
116+
$message ?: '%s is not a SAML2-compliant URI',
117+
ProtocolViolationException::class,
118+
);
119+
120+
$state['IdPMetadata']['entityid'] = $value;
121+
}
122+
123+
124+
/**
125+
* Retrieve the scope attribute from the state and test it for erroneous conditions
126+
*
127+
* @param array $state
128+
* @return string|null
129+
* @throws \SimpleSAML\Assert\AssertionFailedException if the scope is an empty string
130+
* @throws \SAML2\Exception\ProtocolViolationException if the pre-conditions are not met
131+
*/
132+
protected function getScopedAttribute(array $state): ?string
133+
{
134+
if (!array_key_exists('Attributes', $state) || !array_key_exists($this->scopedAttribute, $state['Attributes'])) {
135+
return null;
136+
}
137+
138+
$scope = $state['Attributes'][$this->scopedAttribute][0];
139+
Assert::stringNotEmpty($scope, 'saml:ScopedIssuer: \'scopedAttribute\' cannot be an empty string.');
140+
141+
// If the value is scoped, extract the scope from it
142+
if (strpos($scope, '@') !== false) {
143+
$scope = explode('@', $scope, 2);
144+
$scope = $scope[1];
145+
}
146+
147+
Assert::regex(
148+
$scope,
149+
self::SCOPE_PATTERN,
150+
'saml:ScopedIssuer: \'scopedAttribute\' contains illegal characters.',
151+
);
152+
153+
return $scope;
154+
}
155+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SimpleSAML\Test\Module\saml\Auth\Process;
6+
7+
use PHPUnit\Framework\Attributes\CoversClass;
8+
use PHPUnit\Framework\TestCase;
9+
use SAML2\Exception\ProtocolViolationException;
10+
use SimpleSAML\Assert\AssertionFailedException;
11+
use SimpleSAML\Module\saml\Auth\Process\ScopedIssuer;
12+
13+
/**
14+
* Test for the saml:ScopedIssuer filter.
15+
*/
16+
#[CoversClass(ScopedIssuer::class)]
17+
class ScopedIssuerTest extends TestCase
18+
{
19+
/**
20+
* Helper function to run the filter with a given configuration.
21+
*
22+
* @param array $config The filter configuration.
23+
* @param array $request The request state.
24+
* @return array The state array after processing.
25+
*/
26+
private static function processFilter(array $config, array $request): array
27+
{
28+
$filter = new ScopedIssuer($config, null);
29+
$filter->process($request);
30+
31+
return $request;
32+
}
33+
34+
35+
/**
36+
* Test the most basic functionality
37+
*/
38+
public function testBasic(): void
39+
{
40+
$config = ['scopedAttribute' => 'userPrincipalName', 'pattern' => 'https://%1$s/issuer'];
41+
$request = [
42+
'Attributes' => ['userPrincipalName' => ['[email protected]']],
43+
];
44+
$result = self::processFilter($config, $request);
45+
$this->assertEquals('https://example.org/issuer', $result['IdPMetadata']['entityid']);
46+
}
47+
48+
49+
/**
50+
* Test the most basic functionality, but with an unscoped scope-attribute
51+
*/
52+
public function testUnscopedScope(): void
53+
{
54+
$config = ['scopedAttribute' => 'userPrincipalName', 'pattern' => 'https://%1$s/issuer'];
55+
$request = [
56+
'Attributes' => ['userPrincipalName' => ['example.org']],
57+
];
58+
$result = self::processFilter($config, $request);
59+
$this->assertEquals('https://example.org/issuer', $result['IdPMetadata']['entityid']);
60+
}
61+
62+
63+
/**
64+
* Test that illegal characters in scope throws an exception.
65+
*/
66+
public function testScopeIllegalCharacterThrowsException(): void
67+
{
68+
$config = ['scopedAttribute' => 'userPrincipalName', 'pattern' => 'https://%1$s/issuer'];
69+
$request = [
70+
'Attributes' => ['userPrincipalName' => ['user2@ex%ample.org']],
71+
];
72+
73+
$this->expectException(AssertionFailedException::class);
74+
self::processFilter($config, $request);
75+
}
76+
77+
78+
/**
79+
* Test that a pattern that doesn't resolve into a valid SAML entityID throws an exception.
80+
*/
81+
public function testScopeIllegalPatternThrowsException(): void
82+
{
83+
$config = ['scopedAttribute' => 'userPrincipalName', 'pattern' => 'this(is*not~an!entityid-%1$s'];
84+
$request = [
85+
'Attributes' => ['userPrincipalName' => ['[email protected]']],
86+
];
87+
88+
$this->expectException(ProtocolViolationException::class);
89+
self::processFilter($config, $request);
90+
}
91+
}

0 commit comments

Comments
 (0)