From c7e80baa5e80c9b3fab34842feb1faf499e45056 Mon Sep 17 00:00:00 2001 From: Tim van Dijen Date: Sun, 1 Sep 2024 18:27:07 +0200 Subject: [PATCH] Feature/standalone metadata (#21) * Do not rely on SimpleSAMLphp for metadata-building * Fix * Introduce a clokc * Generate ID * Cleanup --- composer.json | 2 + src/Controller/Adfs.php | 135 +------------- src/IdP/ADFS.php | 1 - src/IdP/MetadataBuilder.php | 347 ++++++++++++++++++++++++++++++++++++ 4 files changed, 358 insertions(+), 127 deletions(-) create mode 100644 src/IdP/MetadataBuilder.php diff --git a/composer.json b/composer.json index 682b22e..12ce0da 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,8 @@ "php": "^8.1", "ext-dom": "*", + "beste/clock": "^3.0", + "psr/clock": "^1.0", "simplesamlphp/assert": "^1.1", "simplesamlphp/saml11": "^1.0", "simplesamlphp/saml2": "^5@dev", diff --git a/src/Controller/Adfs.php b/src/Controller/Adfs.php index 2596602..f733fd8 100644 --- a/src/Controller/Adfs.php +++ b/src/Controller/Adfs.php @@ -5,19 +5,11 @@ namespace SimpleSAML\Module\adfs\Controller; use Exception; -use SimpleSAML\Configuration; +use SimpleSAML\{Configuration, IdP, Logger, Metadata, Module, Session, Utils}; use SimpleSAML\Error as SspError; -use SimpleSAML\IdP; -use SimpleSAML\Logger; -use SimpleSAML\Metadata; -use SimpleSAML\Module; use SimpleSAML\Module\adfs\IdP\ADFS as ADFS_IDP; -use SimpleSAML\SAML2\Constants as C; -use SimpleSAML\Session; -use SimpleSAML\Utils; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\StreamedResponse; +use SimpleSAML\Module\adfs\IdP\MetadataBuilder; +use Symfony\Component\HttpFoundation\{Request, Response, StreamedResponse}; /** * Controller class for the adfs module. @@ -79,123 +71,14 @@ public function metadata(Request $request): Response } $idpmeta = $this->metadata->getMetaDataConfig($idpentityid, 'adfs-idp-hosted'); - $availableCerts = []; - $keys = []; - $certInfo = $this->cryptoUtils->loadPublicKey($idpmeta, false, 'new_'); + $builder = new MetadataBuilder($this->config, $idpmeta); - if ($certInfo !== null) { - $availableCerts['new_idp.crt'] = $certInfo; - $keys[] = [ - 'type' => 'X509Certificate', - 'signing' => true, - 'encryption' => true, - 'X509Certificate' => $certInfo['certData'], - ]; - $hasNewCert = true; - } else { - $hasNewCert = false; - } - - /** @var array $certInfo */ - $certInfo = $this->cryptoUtils->loadPublicKey($idpmeta, true); - $availableCerts['idp.crt'] = $certInfo; - $keys[] = [ - 'type' => 'X509Certificate', - 'signing' => true, - 'encryption' => ($hasNewCert ? false : true), - 'X509Certificate' => $certInfo['certData'], - ]; - - if ($idpmeta->hasValue('https.certificate')) { - /** @var array $httpsCert */ - $httpsCert = $this->cryptoUtils->loadPublicKey($idpmeta, true, 'https.'); - Assert::keyExists($httpsCert, 'certData'); - $availableCerts['https.crt'] = $httpsCert; - $keys[] = [ - 'type' => 'X509Certificate', - 'signing' => true, - 'encryption' => false, - 'X509Certificate' => $httpsCert['certData'], - ]; - } - - $adfs_service_location = Module::getModuleURL('adfs') . '/idp/prp.php'; - $metaArray = [ - 'metadata-set' => 'adfs-idp-remote', - 'entityid' => $idpentityid, - 'SingleSignOnService' => [ - 0 => [ - 'Binding' => C::BINDING_HTTP_REDIRECT, - 'Location' => $adfs_service_location, - ], - ], - 'SingleLogoutService' => [ - 0 => [ - 'Binding' => C::BINDING_HTTP_REDIRECT, - 'Location' => $adfs_service_location, - ], - ], - ]; - - if (count($keys) === 1) { - $metaArray['certData'] = $keys[0]['X509Certificate']; - } else { - $metaArray['keys'] = $keys; - } - - $metaArray['NameIDFormat'] = $idpmeta->getOptionalString( - 'NameIDFormat', - C::NAMEID_TRANSIENT, - ); - - if ($idpmeta->hasValue('OrganizationName')) { - $metaArray['OrganizationName'] = $idpmeta->getLocalizedString('OrganizationName'); - $metaArray['OrganizationDisplayName'] = $idpmeta->getOptionalLocalizedString( - 'OrganizationDisplayName', - $metaArray['OrganizationName'], - ); - - if (!$idpmeta->hasValue('OrganizationURL')) { - throw new SspError\Exception('If OrganizationName is set, OrganizationURL must also be set.'); - } - $metaArray['OrganizationURL'] = $idpmeta->getLocalizedString('OrganizationURL'); - } - - if ($idpmeta->hasValue('scope')) { - $metaArray['scope'] = $idpmeta->getArray('scope'); - } - - if ($idpmeta->hasValue('EntityAttributes')) { - $metaArray['EntityAttributes'] = $idpmeta->getArray('EntityAttributes'); - } - - if ($idpmeta->hasValue('UIInfo')) { - $metaArray['UIInfo'] = $idpmeta->getArray('UIInfo'); - } - - if ($idpmeta->hasValue('DiscoHints')) { - $metaArray['DiscoHints'] = $idpmeta->getArray('DiscoHints'); - } - - if ($idpmeta->hasValue('RegistrationInfo')) { - $metaArray['RegistrationInfo'] = $idpmeta->getArray('RegistrationInfo'); - } - - $metaBuilder = new Metadata\SAMLBuilder($idpentityid); - $metaBuilder->addSecurityTokenServiceType($metaArray); - $metaBuilder->addOrganizationInfo($metaArray); - $technicalContactEmail = $this->config->getOptionalString('technicalcontact_email', null); - if ($technicalContactEmail !== null && $technicalContactEmail !== 'na@example.org') { - $metaBuilder->addContact(Utils\Config\Metadata::getContact([ - 'emailAddress' => $technicalContactEmail, - 'givenName' => $this->config->getOptionalString('technicalcontact_name', null), - 'contactType' => 'technical', - ])); - } - $metaxml = $metaBuilder->getEntityDescriptorText(); + $document = $builder->buildDocument()->toXML(); + // Some products like DirX are known to break on pretty-printed XML + $document->ownerDocument->formatOutput = false; + $document->ownerDocument->encoding = 'UTF-8'; - // sign the metadata if enabled - $metaxml = Metadata\Signer::sign($metaxml, $idpmeta->toArray(), 'ADFS IdP'); + $metaxml = $document->ownerDocument->saveXML(); $response = new Response(); $response->setEtag(hash('sha256', $metaxml)); diff --git a/src/IdP/ADFS.php b/src/IdP/ADFS.php index 05e9d32..94732e2 100644 --- a/src/IdP/ADFS.php +++ b/src/IdP/ADFS.php @@ -8,7 +8,6 @@ use DateTimeImmutable; use DateTimeZone; use Exception; -use SimpleSAML\Assert\Assert; use SimpleSAML\Configuration; use SimpleSAML\Error; use SimpleSAML\IdP; diff --git a/src/IdP/MetadataBuilder.php b/src/IdP/MetadataBuilder.php new file mode 100644 index 0000000..c572608 --- /dev/null +++ b/src/IdP/MetadataBuilder.php @@ -0,0 +1,347 @@ +clock = LocalizedClock::in('Z'); + } + + + /** + * Build a metadata document + * + * @return \SimpleSAML\SAML2\XML\md\EntityDescriptor + */ + public function buildDocument(): EntityDescriptor + { + $entityId = $this->metadata->getString('entityid'); + $contactPerson = $this->getContactPerson(); + $organization = $this->getOrganization(); + $roleDescriptor = $this->getRoleDescriptor(); + + $randomUtils = new Utils\Random(); + $entityDescriptor = new EntityDescriptor( + id: $randomUtils->generateID(), + entityId: $entityId, + contactPerson: $contactPerson, + organization: $organization, + roleDescriptor: $roleDescriptor, + ); + + if ($this->config->getOptionalBoolean('metadata.sign.enable', false) === true) { + $this->signDocument($entityDescriptor); + } + + return $entityDescriptor; + } + + + /** + * @param \SimpleSAML\SAML2\XML\md\AbstractMetadataDocument $document + * @return \SimpleSAML\SAML2\XML\md\AbstractMetadataDocument + */ + protected function signDocument(AbstractMetadataDocument $document): AbstractMetadataDocument + { + $cryptoUtils = new Utils\Crypto(); + + /** @var array $keyArray */ + $keyArray = $cryptoUtils->loadPrivateKey($this->config, true, 'metadata.sign.'); + $certArray = $cryptoUtils->loadPublicKey($this->config, false, 'metadata.sign.'); + $algo = $this->config->getOptionalString('metadata.sign.algorithm', C::SIG_RSA_SHA256); + + $key = PrivateKey::fromFile($keyArray['PEM'], $keyArray['password'] ?? ''); + $signer = (new SignatureAlgorithmFactory())->getAlgorithm($algo, $key); + + $keyInfo = null; + if ($certArray !== null) { + $keyInfo = new KeyInfo([ + new X509Data([ + new X509Certificate($certArray['certData']), + ]), + ]); + } + + $document->sign($signer, C::C14N_EXCLUSIVE_WITHOUT_COMMENTS, $keyInfo); + return $document; + } + + + /** + * This method builds the md:Organization element, if any + */ + private function getOrganization(): ?Organization + { + if ( + !$this->metadata->hasValue('OrganizationName') || + !$this->metadata->hasValue('OrganizationDisplayName') || + !$this->metadata->hasValue('OrganizationURL') + ) { + // empty or incomplete organization information + return null; + } + + $arrayUtils = new Utils\Arrays(); + $org = null; + + try { + $org = Organization::fromArray([ + 'OrganizationName' => $arrayUtils->arrayize($this->metadata->getArray('OrganizationName'), 'en'), + 'OrganizationDisplayName' => $arrayUtils->arrayize( + $this->metadata->getArray('OrganizationDisplayName'), + 'en', + ), + 'OrganizationURL' => $arrayUtils->arrayize($this->metadata->getArray('OrganizationURL'), 'en'), + ]); + } catch (ArrayValidationException $e) { + Logger::error('Federation: invalid content found in contact: ' . $e->getMessage()); + } + + return $org; + } + + + /** + * This method builds the role descriptor elements + */ + private function getRoleDescriptor(): array + { + $descriptors = []; + + $set = $this->metadata->getString('metadata-set'); + switch ($set) { + case 'adfs-idp-hosted': + $descriptors[] = $this->getSecurityTokenService(); + break; + default: + throw new Exception('Not implemented'); + } + + return $descriptors; + } + + + /** + * This method builds the SecurityTokenService element + */ + public function getSecurityTokenService(): SecurityTokenServiceType + { + $defaultEndpoint = Module::getModuleURL('adfs') . '/idp/prp.php'; + + return new SecurityTokenServiceType( + protocolSupportEnumeration: [C::NS_TRUST, C::NS_FED], + keyDescriptors: $this->getKeyDescriptor(), + tokenTypesOffered: new TokenTypesOffered([new TokenType('urn:oasis:names:tc:SAML:1.0:assertion')]), + securityTokenServiceEndpoint: [ + new SecurityTokenServiceEndpoint([ + new EndpointReference(new Address($defaultEndpoint)), + ]), + ], + passiveRequestorEndpoint: [ + new PassiveRequestorEndpoint([ + new EndpointReference(new Address($defaultEndpoint)), + ]), + ], + ); + } + + + /** + * This method builds the md:KeyDescriptor elements, if any + */ + private function getKeyDescriptor(): array + { + $keyDescriptor = []; + + $keys = $this->metadata->getPublicKeys(); + foreach ($keys as $key) { + if ($key['type'] !== 'X509Certificate') { + continue; + } + if (!isset($key['signing']) || $key['signing'] === true) { + $keyDescriptor[] = self::buildKeyDescriptor( + 'signing', + $key['X509Certificate'], + $key['name'] ?? null, + ); + } + if (!isset($key['encryption']) || $key['encryption'] === true) { + $keyDescriptor[] = self::buildKeyDescriptor( + 'encryption', + $key['X509Certificate'], + $key['name'] ?? null, + )); + } + } + + if ($this->metadata->hasValue('https.certData')) { + $keyDescriptor[] = self::buildKeyDescriptor('signing', $this->metadata->getString('https.certData'), null); + } + + return $keyDescriptor; + } + + + /** + * This method builds the md:ContactPerson elements, if any + */ + private function getContactPerson(): array + { + $contacts = []; + + foreach ($this->metadata->getOptionalArray('contacts', []) as $contact) { + if (array_key_exists('ContactType', $contact) && array_key_exists('EmailAddress', $contact)) { + $contacts[] = ContactPerson::fromArray($contact); + } + } + + return $contacts; + } + + + /** + * This method builds the md:Extensions, if any + */ + private function getExtensions(): ?Extensions + { + $extensions = []; + + if ($this->metadata->hasValue('scope')) { + foreach ($this->metadata->getArray('scope') as $scopetext) { + $isRegexpScope = (1 === preg_match('/[\$\^\)\(\*\|\\\\]/', $scopetext)); + $extensions[] = new Scope($scopetext, $isRegexpScope); + } + } + + if ($this->metadata->hasValue('EntityAttributes')) { + $attr = []; + foreach ($this->metadata->getArray('EntityAttributes') as $attributeName => $attributeValues) { + $attrValues = []; + foreach ($attributeValues as $attributeValue) { + $attrValues[] = new AttributeValue($attributeValue); + } + + // Attribute names that is not URI is prefixed as this: '{nameformat}name' + if (preg_match('/^\{(.*?)\}(.*)$/', $attributeName, $matches)) { + $attr[] = new Attribute( + name: $matches[2], + nameFormat: $matches[1] === C::NAMEFORMAT_UNSPECIFIED ? null : $matches[1], + attributeValue: $attrValues, + ); + } else { + $attr[] = new Attribute( + name: $attributeName, + nameFormat: C::NAMEFORMAT_UNSPECIFIED, + attributeValue: $attrValues, + ); + } + } + + $extensions[] = new EntityAttributes($attr); + } + + if ($this->metadata->hasValue('saml:Extensions')) { + $chunks = $this->metadata->getArray('saml:Extensions'); + Assert::allIsInstanceOf($chunks, Chunk::class); + $extensions = array_merge($extensions, $chunks); + } + + if ($this->metadata->hasValue('RegistrationInfo')) { + try { + $extensions[] = RegistrationInfo::fromArray($this->metadata->getArray('RegistrationInfo')); + } catch (ArrayValidationException $err) { + Logger::error('Metadata: invalid content found in RegistrationInfo: ' . $err->getMessage()); + } + } + + if ($this->metadata->hasValue('UIInfo')) { + try { + $extensions[] = UIInfo::fromArray($this->metadata->getArray('UIInfo')); + } catch (ArrayValidationException $err) { + Logger::error('Metadata: invalid content found in UIInfo: ' . $err->getMessage()); + } + } + + if ($this->metadata->hasValue('DiscoHints')) { + try { + $extensions[] = DiscoHints::fromArray($this->metadata->getArray('DiscoHints')); + } catch (ArrayValidationException $err) { + Logger::error('Metadata: invalid content found in DiscoHints: ' . $err->getMessage()); + } + } + + if ($extensions !== []) { + return new Extensions($extensions); + } + + return null; + } + + + private static function buildKeyDescriptor(string $use, string $x509Cert, ?string $keyName): KeyDescriptor + { + Assert::oneOf($use, ['encryption', 'signing']); + $info = [ + new X509Data([ + new X509Certificate($x509Cert), + ]), + ]; + + if ($keyName !== null) { + $info[] = new KeyName($keyName); + } + + return new KeyDescriptor( + new KeyInfo($info), + $use, + ); + } +}