Skip to content

Commit

Permalink
WIP: Create ServiceProvider class
Browse files Browse the repository at this point in the history
  • Loading branch information
tvdijen committed Jul 26, 2024
1 parent f10f3d5 commit 79c4caf
Show file tree
Hide file tree
Showing 2 changed files with 554 additions and 0 deletions.
357 changes: 357 additions & 0 deletions src/SAML2/Entity/ServiceProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,357 @@
<?php

declare(strict_types=1);

namespace SimpleSAML\SAML2\Entity;

use Exception;
use Psr\Http\Message\ServerRequestInterface;
use SimpleSAML\Assert\Assert;
use SimpleSAML\SAML2\{
Binding,
HTTPArtifact,
Metadata,
MetadataProviderInterface,
StateProviderInterface,
StorageProviderInterface,
Utils,
};
use SimpleSAML\SAML2\Exception\{MetadataNotFoundException, RemoteException, RuntimeException};
use SimpleSAML\SAML2\Exception\Protocol\{RequestDeniedException, ResourceNotRecognizedException};
use SimpleSAML\SAML2\Process\Validator\ResponseValidator;
use SimpleSAML\SAML2\XML\saml\{
Assertion,
AttributeStatement,
EncryptedAssertion,
EncryptedAttribute,
EncryptedID,
Subject,
};
use SimpleSAML\SAML2\XML\samlp\Response;
use SimpleSAML\XMLSecurity\Alg\Encryption\EncryptionAlgorithmFactory;
use SimpleSAML\XMLSecurity\Exception\SignatureVerificationFailedException;
use SimpleSAML\XMLSecurity\XML\{
EncryptableElementInterface,
EncryptedElementInterface,
SignableElementInterface,
SignedElementInterface,
};

use function sprintf;

/**
* Class representing a SAML 2 Service Provider.
*
* @package simplesamlphp/saml2
*/
final class ServiceProvider
{
protected ?StateProviderInterface $stateProvider = null;
protected ?StorageProviderInterface $storageProvider = null;
protected ?Metadata\IdentityProvider $idpMetadata = null;


/**
* @param bool $encryptedAssertions Whether assertions must be encrypted
* @param bool $disableScoping Whether to send the samlp:Scoping element in requests
* @param bool $enableUnsolicited Whether to process unsolicited responses
* @param bool $encryptNameId Whether to encrypt the NameID sent
* @param bool $signAuthnRequest Whether to sign the AuthnRequest sent
* @param bool $signLogout Whether to sign the LogoutRequest/LogoutResponse sent
* @param bool $validateLogout Whether to validate the signature of LogoutRequest/LogoutResponse received
*/
public function __construct(
protected MetadataProviderInterface $metadataProvider,
protected Metadata\ServiceProvider $spMetadata,
protected readonly bool $encryptedAssertions = false,
protected readonly bool $disableScoping = false,
protected readonly bool $enableUnsolicited = false,
protected readonly bool $encryptNameId = false,
protected readonly bool $signAuthnRequest = false,
protected readonly bool $signLogout = false,
protected readonly bool $validateLogout = true,
// Use with caution - will leave any form of signature verification or token decryption up to the implementer
protected readonly bool $bypassResponseVerification = false,
// Use with caution - will leave any form of constraint validation up to the implementer
protected readonly bool $bypassConstraintValidation = false,
) {
}


/**
*/
public function setStateProvider(StateProviderInterface $stateProvider): void
{
$this->stateProvider = $stateProvider;
}


/**
*/
public function setStorageProvider(StorageProviderInterface $storageProvider): void
{
$this->storageProvider = $storageProvider;
}


/**
* Receive a verified, and optionally validated Response.
*
* Upon receiving the response from the binding, the signature will be validated first.
* Once the signature checks out, the assertions are decrypted, their signatures verified
* and then any encrypted NameID's and/or attributes are decrypted.
*
* @param \Psr\Http\Message\ServerRequestInterface $request
* @return \SimpleSAML\SAML2\XML\samlp\Response The validated response.
*
* @throws \SimpleSAML\SAML2\Exception\Protocol\UnsupportedBindingException
*/
public function receiveResponse(ServerRequestInterface $request): Response
{
$binding = Binding::getCurrentBinding($request);

if ($binding instanceof HTTPArtifact) {
if ($this->storageProvider === null) {
throw new RuntimeException(
"A StorageProvider is required to use the HTTP-Artifact binding.",
);
}

$artifact = $binding->receiveArtifact($request);
$this->idpMetadata = $this->metadataProvider->getIdPMetadataForSha1($artifact->getSourceId());

if ($this->idpMetadata === null) {
throw new MetadataNotFoundException(sprintf(
'No metadata found for remote entity with SHA1 ID: %s',
$artifact->getSourceId(),
));
}

$binding->setIdpMetadata($this->idpMetadata);
$binding->setSPMetadata($this->spMetadata);
}

$rawResponse = $binding->receive($request);
Assert::isInstanceOf($rawResponse, Response::class, ResourceNotRecognizedException::class); // Wrong type of msg

// Will return a raw Response prior to any form of verification
if ($this->bypassResponseVerification === true) {
return $rawResponse;
}

// Fetch the metadata for the remote entity
if (!($binding instanceof HTTPArtifact)) {
$this->idpMetadata = $this->metadataProvider->getIdPMetadata($rawResponse->getIssuer()->getContent());

if ($this->idpMetadata === null) {
throw new MetadataNotFoundException(sprintf(
'No metadata found for remote entity with entityID: %s',
$rawResponse->getIssuer()->getContent(),
));
}
}

// Verify the signature (if any)
$verifiedResponse = $rawResponse->isSigned() ? $this->verifyElementSignature($rawResponse) : $rawResponse;

$state = null;
$stateId = $verifiedResponse->getInResponseTo();

if (!empty($stateId)) {
if ($this->stateProvider === null) {
throw new RuntimeException(
"A StateProvider is required to correlate responses to their initial request.",
);
}

// this should be a response to a request we sent earlier
try {
$state = $this->stateProvider::loadState($stateId, 'saml:sp:sso');
} catch (RuntimeException $e) {
// something went wrong,
Utils::getContainer()->getLogger()->warning(sprintf(
'Could not load state specified by InResponseTo: %s; processing response as unsolicited.',
$e->getMessage(),
));
}
}

$issuer = $verifiedResponse->getIssuer()->getContent();
if ($state === null) {
if ($this->enableUnsolicited === false) {
throw new RequestDeniedException('Unsolicited responses are denied by configuration.');
}
} else {
// check that the issuer is the one we are expecting
Assert::keyExists($state, 'ExpectedIssuer');

if ($state['ExpectedIssuer'] !== $issuer) {
throw new ResourceNotRecognizedException("Issuer doesn't match the one the AuthnRequest was sent to.");
}
}

$this->idpMetadata = $this->metadataProvider->getIdPMetadata($issuer);
if ($this->idpMetadata === null) {
throw new MetadataNotFoundException(sprintf(
'No metadata found for remote identity provider with entityID: %s',
$issuer,
));
}

$responseValidator = ResponseValidator::createResponseValidator(
$this->idpMetadata,
$this->spMetadata,
$binding,
);
$responseValidator->validate($verifiedResponse);

// Decrypt and verify assertions, then rebuild the response.
$verifiedAssertions = $this->decryptAndVerifyAssertions($verifiedResponse->getAssertions());
$decryptedResponse = new Response(
$verifiedResponse->getStatus(),
$verifiedResponse->getIssueInstant(),
$verifiedResponse->getIssuer(),
$verifiedResponse->getID(),
$verifiedResponse->getVersion(),
$verifiedResponse->getInResponseTo(),
$verifiedResponse->getDestination(),
$verifiedResponse->getConsent(),
$verifiedResponse->getExtensions(),
$verifiedAssertions,
);


// Will return a verified and fully decrypted Response prior to any form of validation
if ($this->bypassConstraintValidation === true) {
return $decryptedResponse;
}

// TODO: Validate assertions
return $decryptedResponse;
}


/**
* Process the assertions and decrypt any encrypted elements inside.
*
* @param \SimpleSAML\SAML2\XML\saml\Assertion[] $unverifiedAssertions
* @return \SimpleSAML\SAML2\XML\saml\Assertion[]
*
* @throws \SimpleSAML\SAML2\Exception\RuntimeException if none of the keys could be used to decrypt the element
*/
protected function decryptAndVerifyAssertions(array $unverifiedAssertions): array
{
/**
* See paragraph 6.2 of the SAML 2.0 core specifications for the applicable processing rules
*
* Long story short - Decrypt the assertion first, then validate it's signature
* Once the signature is verified, decrypt any BaseID, NameID or Attribute that's encrypted
*/
$verifiedAssertions = [];
foreach ($unverifiedAssertions as $i => $assertion) {
// Decrypt the assertions
$decryptedAssertion = ($assertion instanceof EncryptedAssertion)
? $this->decryptElement($assertion)
: $assertion;

// Verify the signature on the assertions (if any)
$verifiedAssertion = $this->verifyElementSignature($decryptedAssertion);

// Decrypt the NameID and replace it inside the assertion's Subject
$nameID = $verifiedAssertion->getSubject()?->getIdentifier();

if ($nameID instanceof EncryptedID) {
$decryptedNameID = $this->decryptElement($nameID);
$subject = new Subject($decryptedNameID, $verifiedAssertion->getSubjectConfirmation());
} else {
$subject = $verifiedAssertion->getSubject();
}

// Decrypt any occurrences of EncryptedAttribute and replace them inside the assertion's AttributeStatement
$statements = $verifiedAssertion->getStatements();
foreach ($verifiedAssertion->getStatements() as $j => $statement) {
if ($statement instanceof AttributeStatement) {
$attributes = $statement->getAttributes();
if ($statement->hasEncryptedAttributes()) {
foreach ($statement->getEncryptedAttributes() as $encryptedAttribute) {
$attributes[] = $this->decryptElement($encryptedAttribute);
}
}

$statements[$j] = new AttributeStatement($attributes);
}
}

// Rebuild the Assertion
$verifiedAssertions[] = new Assertion(
$verifiedAssertion->getIssuer(),
$verifiedAssertion->getIssueInstant(),
$verifiedAssertion->getID(),
$subject,
$verifiedAssertion->getConditions(),
$statements,
);
}

return $verifiedAssertions;
}


/**
* Decrypt the given element using the decryption keys provided to us.
*
* @param \SimpleSAML\XMLSecurity\XML\EncryptedElementInterface $element
* @return \SimpleSAML\XMLSecurity\EncryptableElementInterface
*
* @throws \SimpleSAML\SAML2\Exception\RuntimeException if none of the keys could be used to decrypt the element
*/
protected function decryptElement(EncryptedElementInterface $element): EncryptableElementInterface
{
$factory = $this->spMetadata->getEncryptionAlgorithmFactory();

$encryptionAlgorithm = ($factory instanceof EncryptionAlgorithmFactory)
? $element->getEncryptedData()->getEncryptionMethod()
: $element->getEncryptedKey()->getEncryptionMethod();

foreach ($this->spMetadata->getDecriptionKeys() as $decryptionKey) {
$decryptor = $factory->getAlgorithm($encryptionAlgorithm, $decryptionKey);
try {
return $element->decrypt($decryptor);
} catch (Exception $e) {
continue;
}
}

throw new RuntimeException(sprintf(
'Unable to decrypt %s with any of the available keys.',
$element::class,
));
}


/**
* Verify the signature of an element using the available validation keys.
*
* @param \SimpleSAML\XMLSecurity\XML\SignedElementInterface $element
* @return \SimpleSAML\XMLSecurity\XML\SignableElementInterface The validated element.
*
* @throws \SimpleSAML\XMLSecurity\Exception\SignatureVerificationFailedException
*/
protected function verifyElementSignature(SignedElementInterface $element): SignableElementInterface
{
$factory = $this->spMetadata->getSignatureAlgorithmFactory();
$signatureAlgorithm = $element->getSignature()->getSignedInfo()->getSignatureMethod()->getAlgorithm();

foreach ($this->idpMetadata->getValidatingKeys() as $validatingKey) {
$verifier = $factory->getAlgorithm($signatureAlgorithm, $validatingKey);

try {
return $element->verify($verifier);
} catch (SignatureVerificationFailedException $e) {
continue;
}
}

throw new SignatureVerificationFailedException();
}
}
Loading

0 comments on commit 79c4caf

Please sign in to comment.