Skip to content

Commit dc9b8b0

Browse files
committed
WIP: Create ServiceProvider class
1 parent 609122b commit dc9b8b0

3 files changed

Lines changed: 600 additions & 0 deletions

File tree

phpcs.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
<exclude-pattern>**/HTTPRedirectTest.php</exclude-pattern>
3737
<exclude-pattern>**/SOAPTest.php</exclude-pattern>
3838
<exclude-pattern>tests/SAML2/Assertion/Validation/AssertionValidatorTest.php</exclude-pattern>
39+
<exclude-pattern>tests/SAML2/Entity/ServiceProviderTest.php</exclude-pattern>
3940
<exclude-pattern>tests/SAML2/XML/saml/AssertionTest.php</exclude-pattern>
4041
<exclude-pattern>tests/SAML2/XML/saml/AttributeValueTest.php</exclude-pattern>
4142
<exclude-pattern>tests/SAML2/XML/saml/AuthnContextTest.php</exclude-pattern>
Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SimpleSAML\SAML2\Entity;
6+
7+
use Exception;
8+
use Psr\Http\Message\ServerRequestInterface;
9+
use SimpleSAML\Assert\Assert;
10+
use SimpleSAML\SAML2\{
11+
Binding,
12+
Metadata,
13+
MetadataProviderInterface,
14+
StateProviderInterface,
15+
StorageProviderInterface,
16+
Utils,
17+
};
18+
use SimpleSAML\SAML2\Binding\HTTPArtifact;
19+
use SimpleSAML\SAML2\Exception\{MetadataNotFoundException, RemoteException, RuntimeException};
20+
use SimpleSAML\SAML2\Exception\Protocol\{RequestDeniedException, ResourceNotRecognizedException};
21+
use SimpleSAML\SAML2\Process\Validator\ResponseValidator;
22+
use SimpleSAML\SAML2\XML\saml\{
23+
Assertion,
24+
AttributeStatement,
25+
EncryptedAssertion,
26+
EncryptedAttribute,
27+
EncryptedID,
28+
Subject,
29+
};
30+
use SimpleSAML\SAML2\XML\samlp\Response;
31+
use SimpleSAML\XMLSecurity\Alg\Encryption\EncryptionAlgorithmFactory;
32+
use SimpleSAML\XMLSecurity\Exception\SignatureVerificationFailedException;
33+
use SimpleSAML\XMLSecurity\XML\{
34+
EncryptableElementInterface,
35+
EncryptedElementInterface,
36+
SignableElementInterface,
37+
SignedElementInterface,
38+
};
39+
40+
use function sprintf;
41+
42+
/**
43+
* Class representing a SAML 2 Service Provider.
44+
*
45+
* @package simplesamlphp/saml2
46+
*/
47+
final class ServiceProvider
48+
{
49+
protected ?StateProviderInterface $stateProvider = null;
50+
protected ?StorageProviderInterface $storageProvider = null;
51+
protected ?Metadata\IdentityProvider $idpMetadata = null;
52+
53+
54+
/**
55+
* @param bool $encryptedAssertions Whether assertions must be encrypted
56+
* @param bool $disableScoping Whether to send the samlp:Scoping element in requests
57+
* @param bool $enableUnsolicited Whether to process unsolicited responses
58+
* @param bool $encryptNameId Whether to encrypt the NameID sent
59+
* @param bool $signAuthnRequest Whether to sign the AuthnRequest sent
60+
* @param bool $signLogout Whether to sign the LogoutRequest/LogoutResponse sent
61+
* @param bool $validateLogout Whether to validate the signature of LogoutRequest/LogoutResponse received
62+
*/
63+
public function __construct(
64+
protected MetadataProviderInterface $metadataProvider,
65+
protected Metadata\ServiceProvider $spMetadata,
66+
protected readonly bool $encryptedAssertions = false,
67+
protected readonly bool $disableScoping = false,
68+
protected readonly bool $enableUnsolicited = false,
69+
protected readonly bool $encryptNameId = false,
70+
protected readonly bool $signAuthnRequest = false,
71+
protected readonly bool $signLogout = false,
72+
protected readonly bool $validateLogout = true,
73+
// Use with caution - will leave any form of signature verification or token decryption up to the implementer
74+
protected readonly bool $bypassResponseVerification = false,
75+
// Use with caution - will leave any form of constraint validation up to the implementer
76+
protected readonly bool $bypassConstraintValidation = false,
77+
) {
78+
}
79+
80+
81+
/**
82+
*/
83+
public function setStateProvider(StateProviderInterface $stateProvider): void
84+
{
85+
$this->stateProvider = $stateProvider;
86+
}
87+
88+
89+
/**
90+
*/
91+
public function setStorageProvider(StorageProviderInterface $storageProvider): void
92+
{
93+
$this->storageProvider = $storageProvider;
94+
}
95+
96+
97+
/**
98+
* Receive a verified, and optionally validated Response.
99+
*
100+
* Upon receiving the response from the binding, the signature will be validated first.
101+
* Once the signature checks out, the assertions are decrypted, their signatures verified
102+
* and then any encrypted NameID's and/or attributes are decrypted.
103+
*
104+
* @param \Psr\Http\Message\ServerRequestInterface $request
105+
* @return \SimpleSAML\SAML2\XML\samlp\Response The validated response.
106+
*
107+
* @throws \SimpleSAML\SAML2\Exception\Protocol\UnsupportedBindingException
108+
*/
109+
public function receiveResponse(ServerRequestInterface $request): Response
110+
{
111+
$binding = Binding::getCurrentBinding($request);
112+
113+
if ($binding instanceof HTTPArtifact) {
114+
if ($this->storageProvider === null) {
115+
throw new RuntimeException(
116+
"A StorageProvider is required to use the HTTP-Artifact binding.",
117+
);
118+
}
119+
120+
$artifact = $binding->receiveArtifact($request);
121+
$this->idpMetadata = $this->metadataProvider->getIdPMetadataForSha1($artifact->getSourceId());
122+
123+
if ($this->idpMetadata === null) {
124+
throw new MetadataNotFoundException(sprintf(
125+
'No metadata found for remote entity with SHA1 ID: %s',
126+
$artifact->getSourceId(),
127+
));
128+
}
129+
130+
$binding->setIdpMetadata($this->idpMetadata);
131+
$binding->setSPMetadata($this->spMetadata);
132+
}
133+
134+
$rawResponse = $binding->receive($request);
135+
Assert::isInstanceOf($rawResponse, Response::class, ResourceNotRecognizedException::class); // Wrong type of msg
136+
137+
// Will return a raw Response prior to any form of verification
138+
if ($this->bypassResponseVerification === true) {
139+
return $rawResponse;
140+
}
141+
142+
// Fetch the metadata for the remote entity
143+
if (!($binding instanceof HTTPArtifact)) {
144+
$this->idpMetadata = $this->metadataProvider->getIdPMetadata($rawResponse->getIssuer()->getContent());
145+
146+
if ($this->idpMetadata === null) {
147+
throw new MetadataNotFoundException(sprintf(
148+
'No metadata found for remote entity with entityID: %s',
149+
$rawResponse->getIssuer()->getContent(),
150+
));
151+
}
152+
}
153+
154+
// Verify the signature (if any)
155+
$verifiedResponse = $rawResponse->isSigned() ? $this->verifyElementSignature($rawResponse) : $rawResponse;
156+
157+
$state = null;
158+
$stateId = $verifiedResponse->getInResponseTo();
159+
160+
if (!empty($stateId)) {
161+
if ($this->stateProvider === null) {
162+
throw new RuntimeException(
163+
"A StateProvider is required to correlate responses to their initial request.",
164+
);
165+
}
166+
167+
// this should be a response to a request we sent earlier
168+
try {
169+
$state = $this->stateProvider::loadState($stateId, 'saml:sp:sso');
170+
} catch (RuntimeException $e) {
171+
// something went wrong,
172+
Utils::getContainer()->getLogger()->warning(sprintf(
173+
'Could not load state specified by InResponseTo: %s; processing response as unsolicited.',
174+
$e->getMessage(),
175+
));
176+
}
177+
}
178+
179+
$issuer = $verifiedResponse->getIssuer()->getContent();
180+
if ($state === null) {
181+
if ($this->enableUnsolicited === false) {
182+
throw new RequestDeniedException('Unsolicited responses are denied by configuration.');
183+
}
184+
} else {
185+
// check that the issuer is the one we are expecting
186+
Assert::keyExists($state, 'ExpectedIssuer');
187+
188+
if ($state['ExpectedIssuer'] !== $issuer) {
189+
throw new ResourceNotRecognizedException("Issuer doesn't match the one the AuthnRequest was sent to.");
190+
}
191+
}
192+
193+
$this->idpMetadata = $this->metadataProvider->getIdPMetadata($issuer);
194+
if ($this->idpMetadata === null) {
195+
throw new MetadataNotFoundException(sprintf(
196+
'No metadata found for remote identity provider with entityID: %s',
197+
$issuer,
198+
));
199+
}
200+
201+
$responseValidator = ResponseValidator::createResponseValidator(
202+
$this->idpMetadata,
203+
$this->spMetadata,
204+
$binding,
205+
);
206+
$responseValidator->validate($verifiedResponse);
207+
208+
// Decrypt and verify assertions, then rebuild the response.
209+
$verifiedAssertions = $this->decryptAndVerifyAssertions($verifiedResponse->getAssertions());
210+
$decryptedResponse = new Response(
211+
$verifiedResponse->getStatus(),
212+
$verifiedResponse->getIssueInstant(),
213+
$verifiedResponse->getIssuer(),
214+
$verifiedResponse->getID(),
215+
$verifiedResponse->getVersion(),
216+
$verifiedResponse->getInResponseTo(),
217+
$verifiedResponse->getDestination(),
218+
$verifiedResponse->getConsent(),
219+
$verifiedResponse->getExtensions(),
220+
$verifiedAssertions,
221+
);
222+
223+
224+
// Will return a verified and fully decrypted Response prior to any form of validation
225+
if ($this->bypassConstraintValidation === true) {
226+
return $decryptedResponse;
227+
}
228+
229+
// TODO: Validate assertions
230+
return $decryptedResponse;
231+
}
232+
233+
234+
/**
235+
* Process the assertions and decrypt any encrypted elements inside.
236+
*
237+
* @param \SimpleSAML\SAML2\XML\saml\Assertion[] $unverifiedAssertions
238+
* @return \SimpleSAML\SAML2\XML\saml\Assertion[]
239+
*
240+
* @throws \SimpleSAML\SAML2\Exception\RuntimeException if none of the keys could be used to decrypt the element
241+
*/
242+
protected function decryptAndVerifyAssertions(array $unverifiedAssertions): array
243+
{
244+
/**
245+
* See paragraph 6.2 of the SAML 2.0 core specifications for the applicable processing rules
246+
*
247+
* Long story short - Decrypt the assertion first, then validate it's signature
248+
* Once the signature is verified, decrypt any BaseID, NameID or Attribute that's encrypted
249+
*/
250+
$verifiedAssertions = [];
251+
foreach ($unverifiedAssertions as $i => $assertion) {
252+
// Decrypt the assertions
253+
$decryptedAssertion = ($assertion instanceof EncryptedAssertion)
254+
? $this->decryptElement($assertion)
255+
: $assertion;
256+
257+
// Verify the signature on the assertions (if any)
258+
$verifiedAssertion = $this->verifyElementSignature($decryptedAssertion);
259+
260+
// Decrypt the NameID and replace it inside the assertion's Subject
261+
$nameID = $verifiedAssertion->getSubject()?->getIdentifier();
262+
263+
if ($nameID instanceof EncryptedID) {
264+
$decryptedNameID = $this->decryptElement($nameID);
265+
$subject = new Subject($decryptedNameID, $verifiedAssertion->getSubjectConfirmation());
266+
} else {
267+
$subject = $verifiedAssertion->getSubject();
268+
}
269+
270+
// Decrypt any occurrences of EncryptedAttribute and replace them inside the assertion's AttributeStatement
271+
$statements = $verifiedAssertion->getStatements();
272+
foreach ($verifiedAssertion->getStatements() as $j => $statement) {
273+
if ($statement instanceof AttributeStatement) {
274+
$attributes = $statement->getAttributes();
275+
if ($statement->hasEncryptedAttributes()) {
276+
foreach ($statement->getEncryptedAttributes() as $encryptedAttribute) {
277+
$attributes[] = $this->decryptElement($encryptedAttribute);
278+
}
279+
}
280+
281+
$statements[$j] = new AttributeStatement($attributes);
282+
}
283+
}
284+
285+
// Rebuild the Assertion
286+
$verifiedAssertions[] = new Assertion(
287+
$verifiedAssertion->getIssuer(),
288+
$verifiedAssertion->getIssueInstant(),
289+
$verifiedAssertion->getID(),
290+
$subject,
291+
$verifiedAssertion->getConditions(),
292+
$statements,
293+
);
294+
}
295+
296+
return $verifiedAssertions;
297+
}
298+
299+
300+
/**
301+
* Decrypt the given element using the decryption keys provided to us.
302+
*
303+
* @param \SimpleSAML\XMLSecurity\XML\EncryptedElementInterface $element
304+
* @return \SimpleSAML\XMLSecurity\EncryptableElementInterface
305+
*
306+
* @throws \SimpleSAML\SAML2\Exception\RuntimeException if none of the keys could be used to decrypt the element
307+
*/
308+
protected function decryptElement(EncryptedElementInterface $element): EncryptableElementInterface
309+
{
310+
$factory = $this->spMetadata->getEncryptionAlgorithmFactory();
311+
312+
$encryptionAlgorithm = ($factory instanceof EncryptionAlgorithmFactory)
313+
? $element->getEncryptedData()->getEncryptionMethod()
314+
: $element->getEncryptedKey()->getEncryptionMethod();
315+
316+
foreach ($this->spMetadata->getDecriptionKeys() as $decryptionKey) {
317+
$decryptor = $factory->getAlgorithm($encryptionAlgorithm, $decryptionKey);
318+
try {
319+
return $element->decrypt($decryptor);
320+
} catch (Exception $e) {
321+
continue;
322+
}
323+
}
324+
325+
throw new RuntimeException(sprintf(
326+
'Unable to decrypt %s with any of the available keys.',
327+
$element::class,
328+
));
329+
}
330+
331+
332+
/**
333+
* Verify the signature of an element using the available validation keys.
334+
*
335+
* @param \SimpleSAML\XMLSecurity\XML\SignedElementInterface $element
336+
* @return \SimpleSAML\XMLSecurity\XML\SignableElementInterface The validated element.
337+
*
338+
* @throws \SimpleSAML\XMLSecurity\Exception\SignatureVerificationFailedException
339+
*/
340+
protected function verifyElementSignature(SignedElementInterface $element): SignableElementInterface
341+
{
342+
$factory = $this->spMetadata->getSignatureAlgorithmFactory();
343+
$signatureAlgorithm = $element->getSignature()->getSignedInfo()->getSignatureMethod()->getAlgorithm();
344+
345+
foreach ($this->idpMetadata->getValidatingKeys() as $validatingKey) {
346+
$verifier = $factory->getAlgorithm($signatureAlgorithm, $validatingKey);
347+
348+
try {
349+
return $element->verify($verifier);
350+
} catch (SignatureVerificationFailedException $e) {
351+
continue;
352+
}
353+
}
354+
355+
throw new SignatureVerificationFailedException();
356+
}
357+
}

0 commit comments

Comments
 (0)