Skip to content

Commit 991e3c9

Browse files
authored
Merge pull request #9 from simplesamlphp/feature/saml2-upgrade
Rewrite to use saml2v5
2 parents 30e0bc5 + b48737a commit 991e3c9

File tree

4 files changed

+244
-87
lines changed

4 files changed

+244
-87
lines changed

composer.json

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,17 @@
3636
"require": {
3737
"php": "^8.1",
3838

39-
"simplesamlphp/saml2-legacy": "~4.19.0",
39+
"nyholm/psr7": "~1.8.2",
40+
"simplesamlphp/saml2": "~5.0.3",
4041
"simplesamlphp/simplesamlphp": "~2.4.0",
41-
"symfony/http-foundation": "~6.4.0"
42+
"simplesamlphp/xml-common": "~1.25.0",
43+
"simplesamlphp/xml-security": "~1.13.0",
44+
"symfony/http-foundation": "~6.4.0",
45+
"symfony/psr-http-message-bridge": "~6.4.0"
4246
},
4347
"require-dev": {
44-
"simplesamlphp/simplesamlphp-test-framework": "~1.9.2",
45-
"simplesamlphp/xml-security": "~1.13.0"
48+
"beste/clock": "~3.0.0",
49+
"simplesamlphp/simplesamlphp-test-framework": "~1.9.3"
4650
},
4751
"support": {
4852
"issues": "https://github.com/simplesamlphp/simplesamlphp-module-exampleattributeserver/issues",

src/Controller/AttributeServer.php

Lines changed: 219 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,40 @@
44

55
namespace SimpleSAML\Module\exampleattributeserver\Controller;
66

7-
use SAML2\Assertion;
8-
use SAML2\AttributeQuery;
9-
use SAML2\Binding;
10-
use SAML2\Constants;
11-
use SAML2\HTTPPost;
12-
use SAML2\Response;
13-
use SAML2\XML\saml\Issuer;
14-
use SAML2\XML\saml\SubjectConfirmation;
15-
use SAML2\XML\saml\SubjectConfirmationData;
16-
use SimpleSAML\Configuration;
17-
use SimpleSAML\Error;
7+
use DateInterval;
8+
use Nyholm\Psr7\Factory\Psr17Factory;
9+
use SimpleSAML\{Configuration, Error, Logger};
1810
use SimpleSAML\HTTP\RunnableResponse;
19-
use SimpleSAML\Logger;
2011
use SimpleSAML\Metadata\MetaDataStorageHandler;
21-
use SimpleSAML\Module\saml\Message;
12+
use SimpleSAML\SAML2\Binding;
13+
use SimpleSAML\SAML2\Binding\HTTPPost;
14+
use SimpleSAML\SAML2\Constants as C;
15+
use SimpleSAML\SAML2\Utils as SAML2_Utils;
16+
use SimpleSAML\SAML2\XML\saml\{
17+
Assertion,
18+
Attribute,
19+
AttributeStatement,
20+
AttributeValue,
21+
Audience,
22+
AudienceRestriction,
23+
Conditions,
24+
Issuer,
25+
Subject,
26+
SubjectConfirmation,
27+
SubjectConfirmationData,
28+
};
29+
use SimpleSAML\SAML2\XML\samlp\{AttributeQuery, Response, Status, StatusCode};
30+
use SimpleSAML\Utils;
31+
use SimpleSAML\XML\Utils\Random;
32+
use SimpleSAML\XMLSecurity\Alg\Signature\SignatureAlgorithmFactory;
33+
use SimpleSAML\XMLSecurity\Key\PrivateKey;
34+
use SimpleSAML\XMLSecurity\XML\ds\{KeyInfo, X509Certificate, X509Data};
35+
use SimpleSAML\XMLSecurity\XML\SignableElementInterface;
36+
use Symfony\Bridge\PsrHttpMessage\Factory\{HttpFoundationFactory, PsrHttpFactory};
2237
use Symfony\Component\HttpFoundation\Request;
2338

39+
use function array_filter;
40+
2441
/**
2542
* Controller class for the exampleattributeserver module.
2643
*
@@ -67,19 +84,23 @@ public function setMetadataStorageHandler(MetaDataStorageHandler $handler): void
6784
*/
6885
public function main(/** @scrutinizer ignore-unused */ Request $request): RunnableResponse
6986
{
70-
$binding = Binding::getCurrentBinding();
71-
$query = $binding->receive();
72-
if (!($query instanceof AttributeQuery)) {
87+
$psr17Factory = new Psr17Factory();
88+
$psrHttpFactory = new PsrHttpFactory($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory);
89+
$psrRequest = $psrHttpFactory->createRequest($request);
90+
91+
$binding = Binding::getCurrentBinding($psrRequest);
92+
$message = $binding->receive($psrRequest);
93+
if (!($message instanceof AttributeQuery)) {
7394
throw new Error\BadRequest('Invalid message received to AttributeQuery endpoint.');
7495
}
7596

7697
$idpEntityId = $this->metadataHandler->getMetaDataCurrentEntityID('saml20-idp-hosted');
7798

78-
$issuer = $query->getIssuer();
99+
$issuer = $message->getIssuer();
79100
if ($issuer === null) {
80101
throw new Error\BadRequest('Missing <saml:Issuer> in <samlp:AttributeQuery>.');
81102
} else {
82-
$spEntityId = $issuer->getValue();
103+
$spEntityId = $issuer->getContent();
83104
if ($spEntityId === '') {
84105
throw new Error\BadRequest('Empty <saml:Issuer> in <samlp:AttributeQuery>.');
85106
}
@@ -93,73 +114,195 @@ public function main(/** @scrutinizer ignore-unused */ Request $request): Runnab
93114

94115
// The attributes we will return
95116
$attributes = [
96-
'name' => ['value1', 'value2', 'value3'],
97-
'test' => ['test'],
117+
new Attribute(
118+
'name',
119+
C::NAMEFORMAT_UNSPECIFIED,
120+
null,
121+
[
122+
new AttributeValue('value1'),
123+
new AttributeValue('value2'),
124+
new AttributeValue('value3'),
125+
],
126+
),
127+
new Attribute(
128+
'test',
129+
C::NAMEFORMAT_UNSPECIFIED,
130+
null,
131+
[
132+
new AttributeValue('test'),
133+
],
134+
),
98135
];
99136

100-
// The name format of the attributes
101-
$attributeNameFormat = Constants::NAMEFORMAT_UNSPECIFIED;
102-
103137
// Determine which attributes we will return
104-
$returnAttributes = array_keys($query->getAttributes());
105-
if (count($returnAttributes) === 0) {
138+
// @phpstan-ignore identical.alwaysFalse
139+
if (count($attributes) === 0) {
106140
Logger::debug('No attributes requested - return all attributes.');
107-
$returnAttributes = $attributes;
108-
} elseif ($query->getAttributeNameFormat() !== $attributeNameFormat) {
109-
Logger::debug('Requested attributes with wrong NameFormat - no attributes returned.');
110-
$returnAttributes = [];
141+
$attributeStatement = null;
111142
} else {
112-
/** @var array<mixed>$values */
113-
foreach ($returnAttributes as $name => $values) {
114-
if (!array_key_exists($name, $attributes)) {
115-
// We don't have this attribute
116-
unset($returnAttributes[$name]);
117-
continue;
118-
}
119-
if (count($values) === 0) {
120-
// Return all attributes
121-
$returnAttributes[$name] = $attributes[$name];
122-
continue;
123-
}
143+
$returnAttributes = [];
144+
foreach ($message->getAttributes() as $reqAttr) {
145+
foreach ($attributes as $attr) {
146+
if (
147+
$attr->getName() === $reqAttr->getName()
148+
&& $attr->getNameFormat() === $reqAttr->getNameFormat()
149+
) {
150+
// The requested attribute is available
151+
if ($reqAttr->getAttributeValues() === []) {
152+
// If no specific values are requested, return all
153+
$returnAttributes[] = $attr;
154+
} else {
155+
$returnValues = $this->filterAttributeValues(
156+
$reqAttr->getAttributeValues(),
157+
$attr->getAttributeValues(),
158+
);
124159

125-
// Filter which attribute values we should return
126-
$returnAttributes[$name] = array_intersect($values, $attributes[$name]);
160+
$returnAttributes[] = new Attribute(
161+
$attr->getName(),
162+
$attr->getNameFormat(),
163+
null,
164+
$returnValues,
165+
$attr->getAttributesNS(),
166+
);
167+
}
168+
}
169+
}
127170
}
171+
172+
$attributeStatement = $returnAttributes ? (new AttributeStatement($returnAttributes)) : null;
128173
}
129174

130175
// $returnAttributes contains the attributes we should return. Send them
131-
$issuer = new Issuer();
132-
$issuer->setValue($idpEntityId);
133-
134-
$assertion = new Assertion();
135-
$assertion->setIssuer($issuer);
136-
$assertion->setNameId($query->getNameId());
137-
$assertion->setNotBefore(time());
138-
$assertion->setNotOnOrAfter(time() + 300); // 60*5 = 5min
139-
$assertion->setValidAudiences([$spEntityId]);
140-
$assertion->setAttributes($returnAttributes);
141-
$assertion->setAttributeNameFormat($attributeNameFormat);
142-
143-
$sc = new SubjectConfirmation();
144-
$sc->setMethod(Constants::CM_BEARER);
145-
146-
$scd = new SubjectConfirmationData();
147-
$scd->setNotOnOrAfter(time() + 300); // 60*5 = 5min
148-
$scd->setRecipient($endpoint);
149-
$scd->setInResponseTo($query->getId());
150-
$sc->setSubjectConfirmationData($scd);
151-
$assertion->setSubjectConfirmation([$sc]);
152-
153-
Message::addSign($idpMetadata, $spMetadata, $assertion);
154-
155-
$response = new Response();
156-
$response->setRelayState($query->getRelayState());
157-
$response->setDestination($endpoint);
158-
$response->setIssuer($issuer);
159-
$response->setInResponseTo($query->getId());
160-
$response->setAssertions([$assertion]);
161-
Message::addSign($idpMetadata, $spMetadata, $response);
162-
163-
return new RunnableResponse([new HTTPPost(), 'send'], [$response]);
176+
$clock = SAML2_Utils::getContainer()->getClock();
177+
178+
$statements = array_filter([$attributeStatement]);
179+
$assertion = new Assertion(
180+
issuer: new Issuer($idpEntityId),
181+
issueInstant: $clock->now(),
182+
id: (new Random())->generateID(),
183+
subject: new Subject(
184+
identifier: $message->getSubject()->getIdentifier(),
185+
subjectConfirmation: [
186+
new SubjectConfirmation(
187+
method: C::CM_BEARER,
188+
subjectConfirmationData: new SubjectConfirmationData(
189+
notOnOrAfter: $clock->now()->add(new DateInterval('PT300S')),
190+
recipient: $endpoint,
191+
inResponseTo: $message->getId(),
192+
),
193+
),
194+
],
195+
),
196+
conditions: new Conditions(
197+
notBefore: $clock->now(),
198+
notOnOrAfter: $clock->now()->add(new DateInterval('PT300S')),
199+
audienceRestriction: [
200+
new AudienceRestriction([
201+
new Audience($spEntityId),
202+
]),
203+
],
204+
),
205+
statements: $statements,
206+
);
207+
208+
self::addSign($idpMetadata, $spMetadata, $assertion);
209+
210+
$response = new Response(
211+
status: new Status(
212+
new StatusCode(C::STATUS_SUCCESS),
213+
),
214+
issueInstant: $clock->now(),
215+
issuer: $issuer,
216+
id: (new Random())->generateID(),
217+
version: '2.0',
218+
inResponseTo: $message->getId(),
219+
destination: $endpoint,
220+
assertions: [$assertion],
221+
);
222+
223+
self::addSign($idpMetadata, $spMetadata, $response);
224+
225+
/** @var \SimpleSAML\SAML2\Binding\HTTPPost $httpPost */
226+
$httpPost = new HTTPPost();
227+
$httpPost->setRelayState($binding->getRelayState());
228+
229+
return new RunnableResponse([$httpPost, 'send'], [$response]);
230+
}
231+
232+
233+
/**
234+
* @param array<\SimpleSAML\SAML2\XML\saml\AttributeValue> $reqValues
235+
* @param array<\SimpleSAML\SAML2\XML\saml\AttributeValue> $values
236+
*
237+
* @return array<\SimpleSAML\SAML2\XML\saml\AttributeValue>
238+
*/
239+
private function filterAttributeValues(array $reqValues, array $values): array
240+
{
241+
$result = [];
242+
243+
foreach ($reqValues as $x) {
244+
foreach ($values as $y) {
245+
if ($x->getValue() === $y->getValue()) {
246+
$result[] = $y;
247+
}
248+
}
249+
}
250+
251+
return $result;
252+
}
253+
254+
255+
/**
256+
* @deprecated This method is a modified version of \SimpleSAML\Module\saml\Message::addSign and
257+
* should be replaced with a call to a future ServiceProvider-class in the saml2-library
258+
*
259+
* Add signature key and sender certificate to an element (Message or Assertion).
260+
*
261+
* @param \SimpleSAML\Configuration $srcMetadata The metadata of the sender.
262+
* @param \SimpleSAML\Configuration $dstMetadata The metadata of the recipient.
263+
* @param \SimpleSAML\XMLSecurity\XML\SignableElementInterface $element The element we should add the data to.
264+
*/
265+
private static function addSign(
266+
Configuration $srcMetadata,
267+
Configuration $dstMetadata,
268+
SignableElementInterface &$element,
269+
): void {
270+
$dstPrivateKey = $dstMetadata->getOptionalString('signature.privatekey', null);
271+
$cryptoUtils = new Utils\Crypto();
272+
273+
if ($dstPrivateKey !== null) {
274+
/** @var string[] $keyArray */
275+
$keyArray = $cryptoUtils->loadPrivateKey($dstMetadata, true, 'signature.');
276+
$certArray = $cryptoUtils->loadPublicKey($dstMetadata, false, 'signature.');
277+
} else {
278+
/** @var string[] $keyArray */
279+
$keyArray = $cryptoUtils->loadPrivateKey($srcMetadata, true);
280+
$certArray = $cryptoUtils->loadPublicKey($srcMetadata, false);
281+
}
282+
283+
$algo = $dstMetadata->getOptionalString('signature.algorithm', null);
284+
if ($algo === null) {
285+
$algo = $srcMetadata->getOptionalString('signature.algorithm', C::SIG_RSA_SHA256);
286+
}
287+
288+
$privateKey = PrivateKey::fromFile($keyArray['PEM'], $keyArray['password']);
289+
290+
$keyInfo = null;
291+
if ($certArray !== null) {
292+
$keyInfo = new KeyInfo([
293+
new X509Data(
294+
[
295+
new X509Certificate($certArray['PEM']),
296+
],
297+
),
298+
]);
299+
}
300+
301+
$signer = (new SignatureAlgorithmFactory())->getAlgorithm(
302+
$algo,
303+
$privateKey,
304+
);
305+
306+
$element->sign($signer, C::C14N_EXCLUSIVE_WITHOUT_COMMENTS, $keyInfo);
164307
}
165308
}

tests/bootstrap.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
declare(strict_types=1);
44

5+
use Beste\Clock\LocalizedClock;
6+
use SimpleSAML\SAML2\Compat\ContainerSingleton;
7+
use SimpleSAML\SAML2\Compat\MockContainer;
8+
59
$projectRoot = dirname(__DIR__);
610
require_once($projectRoot . '/vendor/autoload.php');
711

@@ -11,3 +15,11 @@
1115
echo "Linking '$linkPath' to '$projectRoot'\n";
1216
symlink($projectRoot, $linkPath);
1317
}
18+
19+
// Load the system clock
20+
$systemClock = LocalizedClock::in(new DateTimeZone('Z'));
21+
22+
// And set the Mock container as the Container to use.
23+
$container = new MockContainer();
24+
$container->setClock($systemClock);
25+
ContainerSingleton::setContainer($container);

0 commit comments

Comments
 (0)