diff --git a/config-templates/config.php b/config-templates/config.php index d2459694dd103a5aa1acd7ee8373071b2c2c22fe..de3dd82d13d26e04e78480d685205faeaf055a9f 100644 --- a/config-templates/config.php +++ b/config-templates/config.php @@ -471,7 +471,7 @@ $config = [ ***********/ /* - * Configuration for enabling/disabling modules. By default the 'core' and 'saml' modules are enabled. + * Configuration for enabling/disabling modules. By default the 'core', 'admin' and 'saml' modules are enabled. * * Example: * @@ -485,6 +485,7 @@ $config = [ 'module.enable' => [ 'exampleauth' => false, 'core' => true, + 'admin' => true, 'saml' => true ], diff --git a/docs/simplesamlphp-metadata-endpoints.md b/docs/simplesamlphp-metadata-endpoints.md index 7200c9187de4ede82b371c1ec2de02306d06e319..aeeff30bf280d332ac51573b13c0812876e672ba 100644 --- a/docs/simplesamlphp-metadata-endpoints.md +++ b/docs/simplesamlphp-metadata-endpoints.md @@ -46,7 +46,7 @@ Array of arrays 'AssertionConsumerService' => [ [ 'index' => 1, - 'isDefault' => TRUE, + 'isDefault' => true, 'Location' => 'https://sp.example.org/ACS', 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', ], diff --git a/docs/simplesamlphp-reference-idp-hosted.md b/docs/simplesamlphp-reference-idp-hosted.md index 122814f7e52ac7551c4ca1a0f96b8f6b4c5569c2..2730c1178356b3403726114acc1c736541ac75d5 100644 --- a/docs/simplesamlphp-reference-idp-hosted.md +++ b/docs/simplesamlphp-reference-idp-hosted.md @@ -381,6 +381,10 @@ See the documentation for those extensions for more details: * [MDRPI extension](./simplesamlphp-metadata-extensions-rpi) * [EntityAttributes](./simplesamlphp-metadata-extensions-attributes) +For other metadata extensions, you can use the `saml:Extensions` option: + +`saml:Extensions` +: An array of `\SAML2\XML\Chunk`s to include in the IdP metadata extensions, at the same level as `EntityAttributes`. Examples -------- @@ -411,3 +415,40 @@ These are some examples of IdP metadata */ 'auth' => 'example-userpass', ]; + +### A custom metadata extension (eduGAIN republish request) ### + +``` +<?php + +$dom = \SAML2\DOMDocumentFactory::create(); +$republishRequest = $dom->createElementNS('http://eduid.cz/schema/metadata/1.0', 'eduidmd:RepublishRequest'); +$republishTarget = $dom->createElementNS('http://eduid.cz/schema/metadata/1.0', 'eduidmd:RepublishTarget', 'http://edugain.org/'); +$republishRequest->appendChild($republishTarget); +$ext = [new \SAML2\XML\Chunk($republishRequest)]; + +$metadata['__DYNAMIC:1__'] = [ + 'host' => '__DEFAULT__', + 'certificate' => 'example.org.crt', + 'privatekey' => 'example.org.pem', + 'auth' => 'example-userpass', + + /* + * The custom metadata extensions. + */ + 'saml:Extensions' => $ext, +]; +``` + +this generates the following metadata: + +``` +<EntityDescriptor entityID="..."> + <Extensions xmlns="urn:oasis:names:tc:SAML:2.0:metadata"> + <eduidmd:RepublishRequest xmlns:eduidmd="http://eduid.cz/schema/metadata/1.0"> + <eduidmd:RepublishTarget>http://edugain.org/</eduidmd:RepublishTarget> + </eduidmd:RepublishRequest> + </Extensions> + <!-- rest of metadata --> +</EntityDescriptor> +``` diff --git a/lib/SimpleSAML/Logger.php b/lib/SimpleSAML/Logger.php index c0b642ec296381640199156e12c09067db7da77a..7bff0cb2e1d337e590cbd3211e7a44fdd895c7d0 100644 --- a/lib/SimpleSAML/Logger.php +++ b/lib/SimpleSAML/Logger.php @@ -250,7 +250,7 @@ class Logger */ public static function stats(string $string): void { - self::log(self::NOTICE, $string, true); + self::log(self::NOTICE, $string, self::$logLevel >= self::NOTICE); } diff --git a/lib/SimpleSAML/Metadata/SAMLBuilder.php b/lib/SimpleSAML/Metadata/SAMLBuilder.php index 47d873405f9b1d9dc67e9d3b9a19fb1fdf964a7e..a41931cce655cfa61bb7ce8df54abd35ca026fb8 100644 --- a/lib/SimpleSAML/Metadata/SAMLBuilder.php +++ b/lib/SimpleSAML/Metadata/SAMLBuilder.php @@ -225,6 +225,12 @@ class SAMLBuilder ); } + if ($metadata->hasValue('saml:Extensions')) { + $this->entityDescriptor->setExtensions( + array_merge($this->entityDescriptor->getExtensions(), $metadata->getArray('saml:Extensions')) + ); + } + if ($metadata->hasValue('RegistrationInfo')) { $ri = new RegistrationInfo(); foreach ($metadata->getArray('RegistrationInfo') as $riName => $riValues) { diff --git a/modules/admin/lib/Controller/Config.php b/modules/admin/lib/Controller/Config.php index 99f49802ea95c2e920c6dcd816e793957398535a..b94d40f6d14548d6f7cd1927443777f653173e41 100644 --- a/modules/admin/lib/Controller/Config.php +++ b/modules/admin/lib/Controller/Config.php @@ -346,25 +346,36 @@ class Config // perform some sanity checks on the configured certificates if ($this->config->getBoolean('enable.saml20-idp', false) !== false) { $handler = MetaDataStorageHandler::getMetadataHandler(); - $metadata = $handler->getMetaDataCurrent('saml20-idp-hosted'); - $metadata_config = Configuration::loadfromArray($metadata); - $private = $cryptoUtils->loadPrivateKey($metadata_config, false); - $public = $cryptoUtils->loadPublicKey($metadata_config, false); + try { + $metadata = $handler->getMetaDataCurrent('saml20-idp-hosted'); + } catch (\Exception $e) { + $matrix[] = [ + 'required' => 'required', + 'descr' => Translate::noop('Hosted IdP metadata present'), + 'enabled'=>false + ]; + } - $matrix[] = [ - 'required' => 'required', - 'descr' => Translate::noop('Matching key-pair for signing assertions'), - 'enabled' => $this->matchingKeyPair($public['PEM'], $private['PEM'], $private['password']), - ]; + if(isset($metadata)) { + $metadata_config = Configuration::loadfromArray($metadata); + $private = $cryptoUtils->loadPrivateKey($metadata_config, false); + $public = $cryptoUtils->loadPublicKey($metadata_config, false); - $private = $cryptoUtils->loadPrivateKey($metadata_config, false, 'new_'); - if ($private !== null) { - $public = $cryptoUtils->loadPublicKey($metadata_config, false, 'new_'); $matrix[] = [ 'required' => 'required', - 'descr' => Translate::noop('Matching key-pair for signing assertions (rollover key)'), + 'descr' => Translate::noop('Matching key-pair for signing assertions'), 'enabled' => $this->matchingKeyPair($public['PEM'], $private['PEM'], $private['password']), ]; + + $private = $cryptoUtils->loadPrivateKey($metadata_config, false, 'new_'); + if ($private !== null) { + $public = $cryptoUtils->loadPublicKey($metadata_config, false, 'new_'); + $matrix[] = [ + 'required' => 'required', + 'descr' => Translate::noop('Matching key-pair for signing assertions (rollover key)'), + 'enabled' => $this->matchingKeyPair($public['PEM'], $private['PEM'], $private['password']), + ]; + } } } diff --git a/modules/admin/lib/Controller/Federation.php b/modules/admin/lib/Controller/Federation.php index 622c612a68124bf25a020a99371019c934f147bc..949b6aeb907a09415bb896a64d47d2652a088dee 100644 --- a/modules/admin/lib/Controller/Federation.php +++ b/modules/admin/lib/Controller/Federation.php @@ -203,7 +203,7 @@ class Federation /** - * Get a list of the hosted IdP entities, including SAML 2, SAML 1.1 and ADFS. + * Get a list of the hosted IdP entities, including SAML 2 and ADFS. * * @return array * @throws \Exception diff --git a/modules/core/docs/authproc_attributeadd.md b/modules/core/docs/authproc_attributeadd.md index ac113d69dc0f60f9b7b333bb8137aa460ef016b1..a63452c522b21425cc8ebd57e5f0ddabdc02b108 100644 --- a/modules/core/docs/authproc_attributeadd.md +++ b/modules/core/docs/authproc_attributeadd.md @@ -12,38 +12,38 @@ Examples Add a single-valued attributes: - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:AttributeAdd', - 'source' => array('myidp'), - ), - ), + 'source' => ['myidp'], + ], + ], Add a multi-valued attribute: - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:AttributeAdd', - 'groups' => array('users', 'members'), - ), - ), + 'groups' => ['users', 'members'], + ], + ], Add multiple attributes: - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:AttributeAdd', 'eduPersonPrimaryAffiliation' => 'student', - 'eduPersonAffiliation' => array('student', 'employee', 'members'), - ), - ), + 'eduPersonAffiliation' => ['student', 'employee', 'members'], + ], + ], Replace an existing attributes: - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:AttributeAdd', '%replace', - 'uid' => array('guest'), - ), - ), + 'uid' => ['guest'], + ], + ], diff --git a/modules/core/docs/authproc_attributealter.md b/modules/core/docs/authproc_attributealter.md index de4b47601dee593c6588e8b8762a8d8af494a868..84d3d6e06239e7d65e576dad4ca44ba98966f8aa 100644 --- a/modules/core/docs/authproc_attributealter.md +++ b/modules/core/docs/authproc_attributealter.md @@ -1,5 +1,5 @@ `core:AttributeAlter` -========== +===================== This filter can be used to substitute and replace different parts of the attribute values based on regular expressions. It can also be used to create new attributes based on existing values, or even to remove blacklisted values from diff --git a/modules/core/docs/authproc_attributecopy.md b/modules/core/docs/authproc_attributecopy.md index 6f663d395006efc064c450ff6a748dd2337f7216..976e8ee4c295e8c6690f8ca87f70f0e9b47f8b89 100644 --- a/modules/core/docs/authproc_attributecopy.md +++ b/modules/core/docs/authproc_attributecopy.md @@ -1,5 +1,5 @@ `core:AttributeCopy` -=================== +==================== Filter that copies attributes. @@ -9,18 +9,18 @@ Examples Copy a single attribute (user's `uid` will be copied to the user's `username`): - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:AttributeCopy', 'uid' => 'username', - ), - ), + ], + ], Copy a single attribute to more then one attribute (user's `uid` will be copied to the user's `username` and to `urn:mace:dir:attribute-def:uid`) - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:AttributeCopy', - 'uid' => array('username', 'urn:mace:dir:attribute-def:uid'), - ), - ), + 'uid' => ['username', 'urn:mace:dir:attribute-def:uid'], + ], + ], diff --git a/modules/core/docs/authproc_attributelimit.md b/modules/core/docs/authproc_attributelimit.md index 73c4406efd4875c5c72ea8503ea32adcc3036c45..220fd54d7cf030b1dd9daa7ec6f821fcd723039c 100644 --- a/modules/core/docs/authproc_attributelimit.md +++ b/modules/core/docs/authproc_attributelimit.md @@ -15,114 +15,114 @@ Here you will find a few examples on how to use this simple module: Limit to the `cn` and `mail` attribute: - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:AttributeLimit', 'cn', 'mail' - ), - ), + ], + ], Allow `eduPersonTargetedID` and `eduPersonAffiliation` by default, but allow the metadata to override the limitation. - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:AttributeLimit', - 'default' => TRUE, + 'default' => true, 'eduPersonTargetedID', 'eduPersonAffiliation', - ), - ), + ], + ], Only allow specific values for an attribute. - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:AttributeLimit', - 'eduPersonEntitlement' => array('urn:x-surfnet:surf.nl:surfdrive:quota:100') - ), - ), + 'eduPersonEntitlement' => ['urn:mace:surf.nl:surfdrive:quota:100'] + ], + ], Only allow specific values for an attribute ignoring case. - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:AttributeLimit', - 'eduPersonEntitlement' => array( + 'eduPersonEntitlement' => [ 'ignoreCase' => true, - 'URN:x-surfnet:surf.nl:SURFDRIVE:quota:100' - ) - ), - ), + 'URN:mace:surf.nl:SURFDRIVE:quota:100' + ] + ], + ], Only allow specific values for an attribute that match a regex pattern - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:AttributeLimit', - 'eduPersonEntitlement' => array( + 'eduPersonEntitlement' => [ 'regex' => true, - '/^urn:x-surfnet:surf/', + '/^urn:mace:surf/', '/^urn:x-IGNORE_Case/i', - ) - ), - ), + ] + ], + ], Don't allow any attributes by default, but allow the metadata to override it. - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:AttributeLimit', - 'default' => TRUE, - ), - ), + 'default' => true, + ], + ], In order to just use the list of attributes defined in the metadata for each service provider, configure the module like this: - 'authproc' => array( + 'authproc' => [ 50 => 'core:AttributeLimit', - ), + ], Then, add the allowed attributes to each service provider metadata, in the `attributes` option: - $metadata['https://saml2sp.example.org'] = array( + $metadata['https://saml2sp.example.org'] = [ 'AssertionConsumerService' => 'https://saml2sp.example.org/simplesaml/module.php/saml/sp/saml2-acs.php/default-sp', 'SingleLogoutService' => 'https://saml2sp.example.org/simplesaml/module.php/saml/sp/saml2-logout.php/default-sp', ... - 'attributes' => array('cn', 'mail'), + 'attributes' => ['cn', 'mail'], ... - ); + ]; Now, let's look to a couple of examples on how to filter out attribute values. First, allow only the entitlements known to be used by a service provider (among other attributes): - $metadata['https://saml2sp.example.org'] = array( + $metadata['https://saml2sp.example.org'] = [ 'AssertionConsumerService' => 'https://saml2sp.example.org/simplesaml/module.php/saml/sp/saml2-acs.php/default-sp', 'SingleLogoutService' => 'https://saml2sp.example.org/simplesaml/module.php/saml/sp/saml2-logout.php/default-sp', ... - 'attributes' => array( + 'attributes' => [ 'uid', 'mail', - 'eduPersonEntitlement' => array( + 'eduPersonEntitlement' => [ 'urn:mace:example.org:admin', 'urn:mace:example.org:user', - ), - ), + ], + ], ... - ); + ]; Now, an example on how to normalize the affiliations sent from an identity provider, to make sure that no custom values ever reach the service providers. Bear in mind that this configuration can be overridden by metadata: - 'authproc' => array( + 'authproc' => [ 50 => 'core:AttributeLimit', - 'default' => TRUE, - 'eduPersonAffiliation' => array( + 'default' => true, + 'eduPersonAffiliation' => [ 'student', 'staff', 'member', 'faculty', 'employee', 'affiliate', - ), - ), + ], + ], diff --git a/modules/core/docs/authproc_attributemap.md b/modules/core/docs/authproc_attributemap.md index 09364dd514969878e660284dd0c20fb6b96622eb..d463ba4f44e4e630d8bb8ab60f89052df5cede15 100644 --- a/modules/core/docs/authproc_attributemap.md +++ b/modules/core/docs/authproc_attributemap.md @@ -15,43 +15,43 @@ Examples Attribute maps embedded as parameters: - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:AttributeMap', 'mail' => 'email', 'uid' => 'user' - 'cn' => array('name', 'displayName'), - ), - ), + 'cn' => ['name', 'displayName'], + ], + ], Attribute map in separate file: - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:AttributeMap', 'name2oid', - ), - ), + ], + ], This filter will use the map file from `simplesamlphp/attributemap/name2oid.php`. Attribute map in a file contained in a module: - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:AttributeMap', 'module:src2dst' - ), - ), + ], + ], This filter will use the map file from `simplesamlphp/modules/module/attributemap/src2dst.php`. Duplicate attributes based on a map file: - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:AttributeMap', 'name2urn', 'name2oid', '%duplicate', - ), - ), + ], + ], diff --git a/modules/core/docs/authproc_attributevaluemap.md b/modules/core/docs/authproc_attributevaluemap.md index eb571a4faae993be5ad0a81d5db546db9a0f72d0..a3925495d2e16d95700eef580ed6cc5b1276422d 100644 --- a/modules/core/docs/authproc_attributevaluemap.md +++ b/modules/core/docs/authproc_attributevaluemap.md @@ -1,5 +1,5 @@ `core:AttributeValueMap` -=================== +======================== Filter that creates a target attribute based on one or more value(s) in source attribute. @@ -16,69 +16,69 @@ either '`cn=student,o=some,o=organization,dc=org`' or '`cn=student,o=other,o=org The '`memberOf`' attribute will be removed (use `%keep`, to keep it) and existing values in '`eduPersonAffiliation`' will be merged (use `%replace` to replace them). - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:AttributeValueMap', 'sourceattribute' => 'memberOf', 'targetattribute' => 'eduPersonAffiliation', - 'values' => array( - 'student' => array( + 'values' => [ + 'student' => [ 'cn=student,o=some,o=organization,dc=org', 'cn=student,o=other,o=organization,dc=org', - ), - ), - ), - ) + ], + ], + ], + ], ### Multiple assignments Add `student`, `employee` and `both` affiliation based on LDAP groupmembership in the `memberOf` attribute. - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:AttributeValueMap', 'sourceattribute' => 'memberOf', 'targetattribute' => 'eduPersonAffiliation', - 'values' => array( - 'student' => array( + 'values' => [ + 'student' => [ 'cn=student,o=some,o=organization,dc=org', 'cn=student,o=other,o=organization,dc=org', - ), - 'employee' => array( + ], + 'employee' => [ 'cn=employees,o=some,o=organization,dc=org', 'cn=employee,o=other,o=organization,dc=org', 'cn=workers,o=any,o=organization,dc=org', - ), - 'both' => array( + ], + 'both' => [ 'cn=student,o=some,o=organization,dc=org', 'cn=student,o=other,o=organization,dc=org', 'cn=employees,o=some,o=organization,dc=org', 'cn=employee,o=other,o=organization,dc=org', 'cn=workers,o=any,o=organization,dc=org', - ), - ), - ), - ) + ], + ], + ], + ], ### Replace and Keep Replace any existing '`affiliation`' attribute values and keep the '`groups`' attribute. - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:AttributeValueMap', 'sourceattribute' => 'groups', 'targetattribute' => 'affiliation', '%replace', '%keep', - 'values' => array( - 'student' => array( + 'values' => [ + 'student' => [ 'cn=student,o=some,o=organization,dc=org', 'cn=student,o=other,o=organization,dc=org', - ), - 'employee' => array( + ], + 'employee' => [ 'cn=employees,o=some,o=organization,dc=org', 'cn=employee,o=other,o=organization,dc=org', 'cn=workers,o=any,o=organization,dc=org', - ), - ), - ), - ) + ], + ], + ], + ], diff --git a/modules/core/docs/authproc_cardinality.md b/modules/core/docs/authproc_cardinality.md index 97b73f86619cb70db8e4c104b2f8cf52e9e9fe46..14886094e4bde8be11db0f8a9149e47277ee6d20 100644 --- a/modules/core/docs/authproc_cardinality.md +++ b/modules/core/docs/authproc_cardinality.md @@ -30,20 +30,20 @@ Examples Require at least one `givenName`, no more than two email addresses, and between two and four values for `eduPersonScopedAffiliation`. - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:Cardinality', - 'givenName' => array('min' => 1), - 'mail' => array('max' => 2), - 'eduPersonScopedAffiliation' => array('min' => 2, 'max' => 4), - ), - ), + 'givenName' => ['min' => 1], + 'mail' => ['max' => 2[, + 'eduPersonScopedAffiliation' => ['min' => 2, 'max' => 4], + ], + ], Use the shorthand notation for min, max: - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:Cardinality', - 'mail' => array(0, 2), - ), - ), + 'mail' => [0, 2], + ], + ], diff --git a/modules/core/docs/authproc_cardinalitysingle.md b/modules/core/docs/authproc_cardinalitysingle.md index 62d2fa7998b666f9d1a0ae1ed63f0d1452e986bd..0242ee1b0d289cf19444d4dbeeea200389417023 100644 --- a/modules/core/docs/authproc_cardinalitysingle.md +++ b/modules/core/docs/authproc_cardinalitysingle.md @@ -32,10 +32,10 @@ Examples Abort with an error if any attribute defined as single-valued in the eduPerson or SCHAC schemas exists and has more than one value: - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:CardinalitySingle', - 'singleValued' => array( + 'singleValued' => [ /* from eduPerson (internet2-mace-dir-eduperson-201602) */ 'eduPersonOrgDN', 'eduPersonPrimaryAffiliation', 'eduPersonPrimaryOrgUnitDN', 'eduPersonPrincipalName', 'eduPersonUniqueId', @@ -45,44 +45,44 @@ Abort with an error if any attribute defined as single-valued in the eduPerson o 'schacMotherTongue', 'schacGender', 'schacDateOfBirth', 'schacPlaceOfBirth', 'schacPersonalTitle', 'schacHomeOrganization', 'schacHomeOrganizationType', 'schacExpiryDate', - ), - ), - ), + ], + ], + ], Abort if multiple values are received for `eduPersonPrincipalName`, but take the first value for `eduPersonPrimaryAffiliation`: - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:CardinalitySingle', - 'singleValued' => array('eduPersonPrincipalName'), - 'firstValue' => array('eduPersonPrimaryAffiliation'), - ), - ), - ), + 'singleValued' => ['eduPersonPrincipalName'], + 'firstValue' => ['eduPersonPrimaryAffiliation'], + ], + ], + ], Construct `eduPersonPrimaryAffiliation` using the first value in `eduPersonAffiliation`: - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:AttributeCopy', 'eduPersonAffiliation' => 'eduPersonPrimaryAffiliation', - ), - 51 => array( + ], + 51 => [ 'class' => 'core:CardinalitySingle', - 'firstValue' => array('eduPersonPrimaryAffiliation'), - ), - ), + 'firstValue' => ['eduPersonPrimaryAffiliation'], + ], + ], Construct a single, comma-separated value version of `eduPersonAffiliation`: - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:AttributeCopy', 'eduPersonAffiliation' => 'eduPersonAffiliationWithCommas', - ), - 51 => array( + ], + 51 => [ 'class' => 'core:CardinalitySingle', - 'flatten' => array('eduPersonAffiliationWithCommas'), + 'flatten' => ['eduPersonAffiliationWithCommas'], 'flattenWith' => ',', - ), - ), + ], + ], diff --git a/modules/core/docs/authproc_generategroups.md b/modules/core/docs/authproc_generategroups.md index b09e07fff8f1fa7f4e0964a23e1e213d9881ac72..4d472eccae3cb3dc2dedcebca2ed3a8e0ff26212 100644 --- a/modules/core/docs/authproc_generategroups.md +++ b/modules/core/docs/authproc_generategroups.md @@ -33,19 +33,19 @@ Examples Default attributes: - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:GenerateGroups', - ), - ), + ], + ], Custom attributes: - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:GenerateGroups', 'someAttribute', 'someOtherAttribute', - ), - ), + ], + ], diff --git a/modules/core/docs/authproc_languageadaptor.md b/modules/core/docs/authproc_languageadaptor.md index c6d681db8d651649c4b5788b1e39072ba63e5fe0..f9efbd742008d515635652b522930fb54c12050f 100644 --- a/modules/core/docs/authproc_languageadaptor.md +++ b/modules/core/docs/authproc_languageadaptor.md @@ -26,17 +26,17 @@ Examples Default attribute (`preferredLanguage`): - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:LanguageAdaptor', - ), - ), + ], + ], Custon attribute: - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:LanguageAdaptor', 'attributename' => 'lang', - ), - ), + ], + ], diff --git a/modules/core/docs/authproc_php.md b/modules/core/docs/authproc_php.md index 3f8125bd1b95386f6b7cf8c089dde3c4e7cb3efb..eaef2d7e6ac5c22c7eb43f4c343402ab50c7cfa1 100644 --- a/modules/core/docs/authproc_php.md +++ b/modules/core/docs/authproc_php.md @@ -25,7 +25,7 @@ Examples Add the `mail` attribute based on the user's `uid` attribute: - 10 => array( + 10 => [ 'class' => 'core:PHP', 'code' => ' if (empty($attributes["uid"])) { @@ -34,25 +34,25 @@ Add the `mail` attribute based on the user's `uid` attribute: $uid = $attributes["uid"][0]; $mail = $uid . "@example.net"; - $attributes["mail"] = array($mail); + $attributes["mail"] = [$mail]; ', - ), + ], Create a random number variable: - 10 => array( + 10 => [ 'class' => 'core:PHP', 'code' => ' - $attributes["random"] = array( + $attributes["random"] = [ (string)rand(), - ); + ]; ', - ), + ], Force a specific NameIdFormat. Useful if an SP misbehaves and requests (or publishes) an incorrect NameId - 90 => array( + 90 => [ 'class' => 'core:PHP', 'code' => '$state["saml:NameIDFormat"] = ["Format" => "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", "AllowCreate" => true];' - ), + ], diff --git a/modules/core/docs/authproc_scopeattribute.md b/modules/core/docs/authproc_scopeattribute.md index 586158798667d2ddc234c195da27e1b7e35b6247..c495044e2c1cf06872d92182acdee03d760b987c 100644 --- a/modules/core/docs/authproc_scopeattribute.md +++ b/modules/core/docs/authproc_scopeattribute.md @@ -38,12 +38,12 @@ Example Add eduPersonScopedAffiliation based on eduPersonAffiliation and eduPersonPrincipalName. - 10 => array( + 10 => [ 'class' => 'core:ScopeAttribute', 'scopeAttribute' => 'eduPersonPrincipalName', 'sourceAttribute' => 'eduPersonAffiliation', 'targetAttribute' => 'eduPersonScopedAffiliation', - ), + ], With values being `eduPersonPrincipalName`: `jdoe@example.edu` and `eduPersonAffiliation`: `faculty`, this will result in the attribute diff --git a/modules/core/docs/authproc_scopefromattribute.md b/modules/core/docs/authproc_scopefromattribute.md index 5eba851b9f8702fbbb322bf6c28ee48d0bbf895b..f768bc0b318123e795c019882dd21a631d9a8eae 100644 --- a/modules/core/docs/authproc_scopefromattribute.md +++ b/modules/core/docs/authproc_scopefromattribute.md @@ -22,10 +22,10 @@ Example Set the `scope` attribute to the scope from the `eduPersonPrincipalName` attribute: - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:ScopeFromAttribute', 'sourceAttribute' => 'eduPersonPrincipalName', 'targetAttribute' => 'scope', - ), - ), + ], + ], diff --git a/modules/core/docs/authproc_statisticswithattribute.md b/modules/core/docs/authproc_statisticswithattribute.md index 4607c059ea5da12c6324c6130e7396aa10d34a27..22baa0420666caa8b5f4f6dc0a56fc18931d24e3 100644 --- a/modules/core/docs/authproc_statisticswithattribute.md +++ b/modules/core/docs/authproc_statisticswithattribute.md @@ -13,7 +13,7 @@ Parameters : The type of the statistics entry. `skipPassive` -: A boolean indicating whether passive requests should be skipped. Defaults to `FALSE`, in which case the type tag is prefixed with 'passive-'. +: A boolean indicating whether passive requests should be skipped. Defaults to `false`, in which case the type tag is prefixed with 'passive-'. Example @@ -21,9 +21,9 @@ Example Log the realm of the user: - 45 => array( + 45 => [ 'class' => 'core:StatisticsWithAttribute', 'attributename' => 'realm', 'type' => 'saml20-idp-SSO', - ), + ], diff --git a/modules/core/docs/authproc_targetedid.md b/modules/core/docs/authproc_targetedid.md index 75635d47cdab8c20dd194b6f3539a1161199f5c0..a73fbd4a4c99a7bdd66dbaf20f807f46459016a2 100644 --- a/modules/core/docs/authproc_targetedid.md +++ b/modules/core/docs/authproc_targetedid.md @@ -14,9 +14,9 @@ Parameters Note: only the first value of the specified attribute is being used for the generation of the identifier. `nameId` -: Set this option to `TRUE` to generate the attribute as in SAML 2 NameID format. +: Set this option to `true` to generate the attribute as in SAML 2 NameID format. This can be used to generate an Internet2 compatible `eduPersonTargetedID` attribute. - Optional, defaults to `FALSE`. + Optional, defaults to `false`. Examples @@ -24,32 +24,32 @@ Examples A custom attribute: - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:TargetedID', 'attributename' => 'eduPersonPrincipalName' - ), - ), + ], + ], Internet2 compatible `eduPersontargetedID`: /* In saml20-idp-hosted.php. */ - $metadata['__DYNAMIC:1__'] = array( + $metadata['__DYNAMIC:1__'] = [ 'host' => '__DEFAULT__', 'auth' => 'example-static', - 'authproc' => array( - 60 => array( + 'authproc' => [ + 60 => [ 'class' => 'core:TargetedID', - 'nameId' => TRUE, - ), - 90 => array( + 'nameId' => true, + ], + 90 => [ 'class' => 'core:AttributeMap', 'name2oid', - ), - ), + ], + ], 'attributes.NameFormat' => 'urn:oasis:names:tc:SAML:2.0:attrname-format:uri', - 'attributeencodings' => array( + 'attributeencodings' => [ 'urn:oid:1.3.6.1.4.1.5923.1.1.1.10' => 'raw', /* eduPersonTargetedID with oid NameFormat. */ - ), - ); + ], + ]; diff --git a/modules/core/docs/authproc_warnshortssointerval.md b/modules/core/docs/authproc_warnshortssointerval.md index 73f24a2bb6fe8eb7889d33eedf0bcc5e89297a21..3ab8cf6ff926ddabff9379bfdcb446d9d00c150b 100644 --- a/modules/core/docs/authproc_warnshortssointerval.md +++ b/modules/core/docs/authproc_warnshortssointerval.md @@ -8,9 +8,9 @@ This is mainly intended to prevent redirect loops between the IdP and the SP. Example ------- - 'authproc' => array( - 50 => array( + 'authproc' => [ + 50 => [ 'class' => 'core:WarnShortSSOInterval', - ), - ), + ], + ], diff --git a/modules/core/lib/Controller/Login.php b/modules/core/lib/Controller/Login.php index 5e2dcca87d8b2ecde8ca2c3161a386578e0f31e7..8d08cf91b201e968971a3dc1ef19fbd84db01a90 100644 --- a/modules/core/lib/Controller/Login.php +++ b/modules/core/lib/Controller/Login.php @@ -186,10 +186,10 @@ class Login // we're not logged in, start auth $url = Module::getModuleURL('core/login/' . $as); - $params = array( + $params = [ 'ErrorURL' => $url, 'ReturnTo' => $url, - ); + ]; return new RunnableResponse([$auth, 'login'], [$params]); } diff --git a/modules/exampleauth/lib/Auth/Process/RedirectTest.php b/modules/exampleauth/lib/Auth/Process/RedirectTest.php index 34c93221251e3e3db553ef66a335436129922068..3167fb6866e094ffeef57950a5c18b0232349aa6 100644 --- a/modules/exampleauth/lib/Auth/Process/RedirectTest.php +++ b/modules/exampleauth/lib/Auth/Process/RedirectTest.php @@ -13,7 +13,7 @@ use SimpleSAML\Utils; * A simple processing filter for testing that redirection works as it should. * */ -class RedirectTest extends \SimpleSAML\Auth\ProcessingFilter +class RedirectTest extends Auth\ProcessingFilter { /** * Initialize processing of the redirect test. @@ -29,7 +29,7 @@ class RedirectTest extends \SimpleSAML\Auth\ProcessingFilter // Save state and redirect $id = Auth\State::saveState($state, 'exampleauth:redirectfilter-test'); - $url = Module::getModuleURL('exampleauth/redirecttest.php'); + $url = Module::getModuleURL('exampleauth/redirecttest'); $httpUtils = new Utils\HTTP(); $httpUtils->redirectTrustedURL($url, ['StateId' => $id]); diff --git a/modules/exampleauth/lib/Auth/Source/External.php b/modules/exampleauth/lib/Auth/Source/External.php index 44595129d149143d66cb4850d82aa7baf79bac52..2e8830fdf688ab2860df457e08a72c0d2723fb34 100644 --- a/modules/exampleauth/lib/Auth/Source/External.php +++ b/modules/exampleauth/lib/Auth/Source/External.php @@ -9,6 +9,8 @@ use SimpleSAML\Auth; use SimpleSAML\Error; use SimpleSAML\Module; use SimpleSAML\Utils; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Session\Session as SymfonySession; /** * Example external authentication source. @@ -19,14 +21,13 @@ use SimpleSAML\Utils; * To adapt this to your own web site, you should: * 1. Create your own module directory. * 2. Enable to module in the config by adding '<module-dir>' => true to the $config['module.enable'] array. - * 3. Copy this file and modules/exampleauth/www/resume.php to their corresponding - * location in the new module. - * 4. Replace all occurrences of "exampleauth" in this file and in resume.php with the name of your module. + * 3. Copy this file to its corresponding location in the new module. + * 4. Replace all occurrences of "exampleauth" in this file with the name of your module. * 5. Adapt the getUser()-function, the authenticate()-function and the logout()-function to your site. * 6. Add an entry in config/authsources.php referencing your module. E.g.: - * 'myauth' => array( + * 'myauth' => [ * '<mymodule>:External', - * ), + * ], * * @package SimpleSAMLphp */ @@ -65,13 +66,12 @@ class External extends Auth\Source * stored in the users PHP session, but this could be replaced * with anything. */ - - if (!session_id()) { - // session_start not called before. Do it here - session_start(); + $session = new SymfonySession(); + if (!$session->getId()) { + $session->start(); } - if (!isset($_SESSION['uid'])) { + if (!$session->has('uid')) { // The user isn't authenticated return null; } @@ -81,16 +81,15 @@ class External extends Auth\Source * Note that all attributes in SimpleSAMLphp are multivalued, so we need * to store them as arrays. */ - $attributes = [ - 'uid' => [$_SESSION['uid']], - 'displayName' => [$_SESSION['name']], - 'mail' => [$_SESSION['mail']], + 'uid' => [$session->get('uid')], + 'displayName' => [$session->get('name')], + 'mail' => [$session->get('mail')], ]; // Here we generate a multivalued attribute based on the account type $attributes['eduPersonAffiliation'] = [ - $_SESSION['type'], /* In this example, either 'student' or 'employee'. */ + $session->get('type'), /* In this example, either 'student' or 'employee'. */ 'member', ]; @@ -148,7 +147,7 @@ class External extends Auth\Source * We assume that whatever authentication page we send the user to has an * option to return the user to a specific page afterwards. */ - $returnTo = Module::getModuleURL('exampleauth/resume.php', [ + $returnTo = Module::getModuleURL('exampleauth/resume', [ 'State' => $stateId, ]); @@ -159,7 +158,7 @@ class External extends Auth\Source * is also part of this module, but in a real example, this would likely be * the absolute URL of the login page for the site. */ - $authPage = Module::getModuleURL('exampleauth/authpage.php'); + $authPage = Module::getModuleURL('exampleauth/authpage'); /* * The redirect to the authentication page. @@ -185,16 +184,18 @@ class External extends Auth\Source * This function resumes the authentication process after the user has * entered his or her credentials. * + * @param \Symfony\Component\HttpFoundation\Request $request + * * @throws \SimpleSAML\Error\BadRequest * @throws \SimpleSAML\Error\Exception */ - public static function resume(): void + public static function resume(Request $request): void { /* * First we need to restore the $state-array. We should have the identifier for * it in the 'State' request parameter. */ - if (!isset($_REQUEST['State'])) { + if (!$request->has('State')) { throw new Error\BadRequest('Missing "State" parameter.'); } @@ -203,7 +204,7 @@ class External extends Auth\Source * match the string we used in the saveState-call above. */ /** @var array $state */ - $state = Auth\State::loadState($_REQUEST['State'], 'exampleauth:External'); + $state = Auth\State::loadState($request->get('State'), 'exampleauth:External'); /* * Now we have the $state-array, and can use it to locate the authentication @@ -266,15 +267,12 @@ class External extends Auth\Source */ public function logout(array &$state): void { - if (!session_id()) { - // session_start not called before. Do it here - session_start(); + $session = new SymfonySession(); + if (!$session->getId()) { + $session->start(); } - /* - * In this example we simply remove the 'uid' from the session. - */ - unset($_SESSION['uid']); + $session->clear(); /* * If we need to do a redirect to a different page, we could do this diff --git a/modules/exampleauth/lib/Controller/ExampleAuth.php b/modules/exampleauth/lib/Controller/ExampleAuth.php new file mode 100644 index 0000000000000000000000000000000000000000..0072a1a66268dc64fe6d53089fd6af862ae926dc --- /dev/null +++ b/modules/exampleauth/lib/Controller/ExampleAuth.php @@ -0,0 +1,213 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\exampleauth\Controller; + +use SimpleSAML\Auth; +use SimpleSAML\Configuration; +use SimpleSAML\Error; +use SimpleSAML\HTTP\RunnableResponse; +use SimpleSAML\Module\exampleauth\Auth\Source\External; +use SimpleSAML\Session; +use SimpleSAML\Utils; +use SimpleSAML\XHTML\Template; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Session\Session as SymfonySession; + +use function array_key_exists; +use function preg_match; +use function session_id; +use function session_start; +use function urldecode; + +/** + * Controller class for the exampleauth module. + * + * This class serves the different views available in the module. + * + * @package simplesamlphp/simplesamlphp + */ +class ExampleAuth +{ + /** @var \SimpleSAML\Configuration */ + protected Configuration $config; + + /** @var \SimpleSAML\Session */ + protected Session $session; + + /** + * @var \SimpleSAML\Auth\State|string + * @psalm-var \SimpleSAML\Auth\State|class-string + */ + protected $authState = Auth\State::class; + + + /** + * Controller constructor. + * + * It initializes the global configuration and session for the controllers implemented here. + * + * @param \SimpleSAML\Configuration $config The configuration to use by the controllers. + * @param \SimpleSAML\Session $session The session to use by the controllers. + * + * @throws \Exception + */ + public function __construct( + Configuration $config, + Session $session + ) { + $this->config = $config; + $this->session = $session; + } + + + /** + * Inject the \SimpleSAML\Auth\State dependency. + * + * @param \SimpleSAML\Auth\State $authState + */ + public function setAuthState(Auth\State $authState): void + { + $this->authState = $authState; + } + + + /** + * Auth testpage. + * + * @param \Symfony\Component\HttpFoundation\Request $request The current request. + * + * @return \SimpleSAML\XHTML\Template|\SimpleSAML\HTTP\RunnableResponse + */ + public function authpage(Request $request) + { + /** + * This page serves as a dummy login page. + * + * Note that we don't actually validate the user in this example. This page + * just serves to make the example work out of the box. + */ + $returnTo = $request->get('ReturnTo'); + if ($returnTo === null) { + throw new Error\Exception('Missing ReturnTo parameter.'); + } + + $httpUtils = new Utils\HTTP(); + $returnTo = $httpUtils->checkURLAllowed($returnTo); + + /** + * The following piece of code would never be found in a real authentication page. Its + * purpose in this example is to make this example safer in the case where the + * administrator of the IdP leaves the exampleauth-module enabled in a production + * environment. + * + * What we do here is to extract the $state-array identifier, and check that it belongs to + * the exampleauth:External process. + */ + if (!preg_match('@State=(.*)@', $returnTo, $matches)) { + throw new Error\Exception('Invalid ReturnTo URL for this example.'); + } + + /** + * The loadState-function will not return if the second parameter does not + * match the parameter passed to saveState, so by now we know that we arrived here + * through the exampleauth:External authentication page. + */ + $this->authState::loadState(urldecode($matches[1]), 'exampleauth:External'); + + // our list of users. + $users = [ + 'student' => [ + 'password' => 'student', + 'uid' => 'student', + 'name' => 'Student Name', + 'mail' => 'somestudent@example.org', + 'type' => 'student', + ], + 'admin' => [ + 'password' => 'admin', + 'uid' => 'admin', + 'name' => 'Admin Name', + 'mail' => 'someadmin@example.org', + 'type' => 'employee', + ], + ]; + + // time to handle login responses; since this is a dummy example, we accept any data + $badUserPass = false; + if ($request->getMethod() === 'POST') { + $username = $request->get('username'); + $password = $request->get('password'); + + if (!isset($users[$username]) || $users[$username]['password'] !== $password) { + $badUserPass = true; + } else { + $user = $users[$username]; + + $session = new SymfonySession(); + if (!$session->getId()) { + $session->start(); + } + + $session->set('uid', $user['uid']); + $session->set('name', $user['name']); + $session->set('mail', $user['mail']); + $session->set('type', $user['type']); + + return new RunnableResponse([$httpUtils, 'redirectTrustedURL'], [$returnTo]); + } + } + + // if we get this far, we need to show the login page to the user + $t = new Template($this->config, 'exampleauth:authenticate.twig'); + $t->data['badUserPass'] = $badUserPass; + $t->data['returnTo'] = $returnTo; + + return $t; + } + + + /** + * Redirect testpage. + * + * @param \Symfony\Component\HttpFoundation\Request $request The current request. + * + * @return \SimpleSAML\HTTP\RunnableResponse + */ + public function redirecttest(Request $request): RunnableResponse + { + /** + * Request handler for redirect filter test. + */ + $stateId = $request->get('StateId'); + if ($stateId === null) { + throw new Error\BadRequest('Missing required StateId query parameter.'); + } + + /** @var array $state */ + $state = $this->authState::loadState($stateId, 'exampleauth:redirectfilter-test'); + $state['Attributes']['RedirectTest2'] = ['OK']; + + return new RunnableResponse([Auth\ProcessingChain::class, 'resumeProcessing'], [$state]); + } + + + /** + * Resume testpage. + * + * @param \Symfony\Component\HttpFoundation\Request $request The current request. + * + * @return \SimpleSAML\HTTP\RunnableResponse + */ + public function resume(Request $request): RunnableResponse + { + /** + * This page serves as the point where the user's authentication + * process is resumed after the login page. + * + * It simply passes control back to the class. + */ + return new RunnableResponse([External::class, 'resume'], [$request]); + } +} diff --git a/modules/exampleauth/routing/routes/routes.yml b/modules/exampleauth/routing/routes/routes.yml new file mode 100644 index 0000000000000000000000000000000000000000..a4b52108f8f0aca5f229338f388727eec1892835 --- /dev/null +++ b/modules/exampleauth/routing/routes/routes.yml @@ -0,0 +1,9 @@ +exampleauth-authpage: + path: /authpage + defaults: { _controller: 'SimpleSAML\Module\exampleauth\Controller\ExampleAuth::authpage' } +exampleauth-redirecttest: + path: /redirecttest + defaults: { _controller: 'SimpleSAML\Module\exampleauth\Controller\ExampleAuth::redirecttest' } +exampleauth-resume: + path: /resume + defaults: { _controller: 'SimpleSAML\Module\exampleauth\Controller\ExampleAuth::resume' } diff --git a/modules/exampleauth/www/authpage.php b/modules/exampleauth/www/authpage.php deleted file mode 100644 index c1ab81bfc63f259f8c4f6e40d9ec9b7bbfff958f..0000000000000000000000000000000000000000 --- a/modules/exampleauth/www/authpage.php +++ /dev/null @@ -1,87 +0,0 @@ -<?php - -/** - * This page serves as a dummy login page. - * - * Note that we don't actually validate the user in this example. This page - * just serves to make the example work out of the box. - * - * @package SimpleSAMLphp - */ - -if (!isset($_REQUEST['ReturnTo'])) { - die('Missing ReturnTo parameter.'); -} - -$httpUtils = new \SimpleSAML\Utils\HTTP(); -$returnTo = $httpUtils->checkURLAllowed($_REQUEST['ReturnTo']); - -/** - * The following piece of code would never be found in a real authentication page. Its - * purpose in this example is to make this example safer in the case where the - * administrator of the IdP leaves the exampleauth-module enabled in a production - * environment. - * - * What we do here is to extract the $state-array identifier, and check that it belongs to - * the exampleauth:External process. - */ -if (!preg_match('@State=(.*)@', $returnTo, $matches)) { - die('Invalid ReturnTo URL for this example.'); -} - -/** - * The loadState-function will not return if the second parameter does not - * match the parameter passed to saveState, so by now we know that we arrived here - * through the exampleauth:External authentication page. - */ -\SimpleSAML\Auth\State::loadState(urldecode($matches[1]), 'exampleauth:External'); - -// our list of users. -$users = [ - 'student' => [ - 'password' => 'student', - 'uid' => 'student', - 'name' => 'Student Name', - 'mail' => 'somestudent@example.org', - 'type' => 'student', - ], - 'admin' => [ - 'password' => 'admin', - 'uid' => 'admin', - 'name' => 'Admin Name', - 'mail' => 'someadmin@example.org', - 'type' => 'employee', - ], -]; - -// time to handle login responses; since this is a dummy example, we accept any data -$badUserPass = false; -if ($_SERVER['REQUEST_METHOD'] === 'POST') { - $username = (string) $_REQUEST['username']; - $password = (string) $_REQUEST['password']; - - if (!isset($users[$username]) || $users[$username]['password'] !== $password) { - $badUserPass = true; - } else { - $user = $users[$username]; - - if (!session_id()) { - // session_start not called before. Do it here. - session_start(); - } - - $_SESSION['uid'] = $user['uid']; - $_SESSION['name'] = $user['name']; - $_SESSION['mail'] = $user['mail']; - $_SESSION['type'] = $user['type']; - - $httpUtils->redirectTrustedURL($returnTo); - } -} - -// if we get this far, we need to show the login page to the user -$config = \SimpleSAML\Configuration::getInstance(); -$t = new \SimpleSAML\XHTML\Template($config, 'exampleauth:authenticate.twig'); -$t->data['badUserPass'] = $badUserPass; -$t->data['returnTo'] = $returnTo; -$t->send(); diff --git a/modules/exampleauth/www/redirecttest.php b/modules/exampleauth/www/redirecttest.php deleted file mode 100644 index 373c8527f400bff23ea506f647f1f5fa8718f554..0000000000000000000000000000000000000000 --- a/modules/exampleauth/www/redirecttest.php +++ /dev/null @@ -1,18 +0,0 @@ -<?php - -/** - * Request handler for redirect filter test. - * - * @package SimpleSAMLphp - */ - -if (!array_key_exists('StateId', $_REQUEST)) { - throw new \SimpleSAML\Error\BadRequest('Missing required StateId query parameter.'); -} - -/** @var array $state */ -$state = \SimpleSAML\Auth\State::loadState($_REQUEST['StateId'], 'exampleauth:redirectfilter-test'); - -$state['Attributes']['RedirectTest2'] = ['OK']; - -\SimpleSAML\Auth\ProcessingChain::resumeProcessing($state); diff --git a/modules/exampleauth/www/resume.php b/modules/exampleauth/www/resume.php deleted file mode 100644 index 192c13a20dceb45230de0044c7cf34a982f0864c..0000000000000000000000000000000000000000 --- a/modules/exampleauth/www/resume.php +++ /dev/null @@ -1,14 +0,0 @@ -<?php - -/** - * This page serves as the point where the user's authentication - * process is resumed after the login page. - * - * It simply passes control back to the class. - * - * @package SimpleSAMLphp - */ - -namespace SimpleSAML\Module\exampleauth\Auth\Source; - -External::resume(); diff --git a/modules/saml/docs/nameid.md b/modules/saml/docs/nameid.md index fb7b77c2d459ee384e5fa56a7a60c70ba7271664..f0b3f77d858459a52de838672cd640088ab78238 100644 --- a/modules/saml/docs/nameid.md +++ b/modules/saml/docs/nameid.md @@ -10,18 +10,18 @@ Common options `NameQualifier` : The NameQualifier attribute for the generated NameID. This can be a string that is used as the value directly. - It can also be `TRUE`, in which case we use the IdP entity ID as the NameQualifier. - If it is `FALSE`, no NameQualifier will be included. + It can also be `true`, in which case we use the IdP entity ID as the NameQualifier. + If it is `false`, no NameQualifier will be included. -: The default is `FALSE`, which means that we will not include a NameQualifier by default. +: The default is `false`, which means that we will not include a NameQualifier by default. `SPNameQualifier` : The SPNameQualifier attribute for the generated NameID. This can be a string that is used as the value directly. - It can also be `TRUE`, in which case we use the SP entity ID as the SPNameQualifier. - If it is `FALSE`, no SPNameQualifier will be included. + It can also be `true`, in which case we use the SP entity ID as the SPNameQualifier. + If it is `false`, no SPNameQualifier will be included. -: The default is `TRUE`, which means that we will use the SP entity ID. +: The default is `true`, which means that we will use the SP entity ID. `saml:AttributeNameID` @@ -77,21 +77,21 @@ See the `store.type` configuration option in `config.php`. `allowUnspecified` : Whether a persistent NameID should be created if the SP does not specify any NameID format in the request. - The default is `FALSE`. + The default is `false`. `allowDifferent` : Whether a persistent NameID should be created if there are only other NameID formats specified in the request or the SP's metadata. - The default is `FALSE`. + The default is `false`. `alwaysCreate` : Whether to ignore an explicit `AllowCreate="false"` in the authentication request's NameIDPolicy. - The default is `FALSE`, which will only create new NameIDs when the SP specifies `AllowCreate="true"` in the authentication request. + The default is `false`, which will only create new NameIDs when the SP specifies `AllowCreate="true"` in the authentication request. `store` : An array of database options passed to `\SimpleSAML\Database`, keys prefixed with `database.`. The default is `[]`, which uses the global SQL datastore. -Setting both `allowUnspecified` and `alwaysCreate` to `TRUE` causes `saml:SQLPersistentNameID` to behave like `saml:PersistentNameID` (and other NameID generation filters), at the expense of creating unnecessary entries in the SQL datastore. +Setting both `allowUnspecified` and `alwaysCreate` to `true` causes `saml:SQLPersistentNameID` to behave like `saml:PersistentNameID` (and other NameID generation filters), at the expense of creating unnecessary entries in the SQL datastore. `saml:PersistentNameID2TargetedID` @@ -111,7 +111,7 @@ This can be used to set the `eduPersonTargetedID`-attribute to the same value as `nameId` : Whether the generated attribute should be an saml:NameID element. - The default is `TRUE`. + The default is `true`. @@ -159,7 +159,7 @@ Generating Persistent NameID and eduPersonTargetedID. 60 => [ 'class' => 'saml:PersistentNameID2TargetedID', 'attribute' => 'eduPersonTargetedID', // The default - 'nameId' => TRUE, // The default + 'nameId' => true, // The default ], // Use OID attribute names. 90 => [ diff --git a/modules/saml/docs/sp.md b/modules/saml/docs/sp.md index afc10630216dd3361516abfda87f78144c4f86f5..808fd7237f8ae3b8715c83c150c8df448611a1e3 100644 --- a/modules/saml/docs/sp.md +++ b/modules/saml/docs/sp.md @@ -64,7 +64,7 @@ All these parameters override the equivalent option from the configuration. `saml:NameIDPolicy` : The format of the NameID we request from the IdP: an array in the form of - `[ 'Format' => the format, 'allowcreate' => true or false ]`. + `[ 'Format' => the format, 'AllowCreate' => true or false ]`. Set to `false` instead of an array to omit sending any specific NameIDPolicy in the AuthnRequest. @@ -108,8 +108,8 @@ Options * `urn:oasis:names:tc:SAML:2.0:profiles:holder-of-key:SSO:browser` `assertion.encryption` -: Whether assertions received by this SP must be encrypted. The default value is `FALSE`. - If this option is set to `TRUE`, unencrypted assertions will be rejected. +: Whether assertions received by this SP must be encrypted. The default value is `false`. + If this option is set to `true`, unencrypted assertions will be rejected. : Note that this option can be overridden for a specific IdP in saml20-idp-remote. @@ -117,7 +117,9 @@ Options `AssertionConsumerService` : List of Assertion Consumer Services in the generated metadata. Specified in the array of arrays format as seen in the [Metadata endpoints](./simplesamlphp-metadata-endpoints) - documentation. + documentation. Note that this list is taken at face value, so it's not useful to list + anything here that the SP auth source does not actually support (unless the URLs point + externally). `AssertionConsumerServiceIndex` : The Assertion Consumer Service Index to be used in the AuthnRequest in place of the Assertion @@ -209,8 +211,8 @@ Options : *Note*: For this to be added to the metadata, you must also specify the `attributes` and `name` options. `disable_scoping` -: Whether sending of samlp:Scoping elements in authentication requests should be suppressed. The default value is `FALSE`. - When set to `TRUE`, no scoping elements will be sent. This does not comply with the SAML2 specification, but allows +: Whether sending of samlp:Scoping elements in authentication requests should be suppressed. The default value is `false`. + When set to `true`, no scoping elements will be sent. This does not comply with the SAML2 specification, but allows interoperability with ADFS which [does not support Scoping elements](https://docs.microsoft.com/en-za/azure/active-directory/develop/active-directory-single-sign-on-protocol-reference#scoping). : Note that this option also exists in the IdP remote configuration. An entry @@ -262,7 +264,7 @@ Options `nameid.encryption` : Whether NameIDs sent from this SP should be encrypted. The default - value is `FALSE`. + value is `false`. : Note that this option can be set for each IdP in the [IdP-remote metadata](./simplesamlphp-reference-idp-remote). @@ -317,13 +319,13 @@ Options `redirect.sign` -: Whether authentication requests, logout requests and logout responses sent from this SP should be signed. The default is `FALSE`. +: Whether authentication requests, logout requests and logout responses sent from this SP should be signed. The default is `false`. If set, the `AuthnRequestsSigned` attribute of the `SPSSODescriptor` element in SAML 2.0 metadata will contain its value. This option takes precedence over the `sign.authnrequest` option in any metadata generated for this SP. `redirect.validate` -: Whether logout requests and logout responses received by this SP should be validated. The default is `FALSE`. +: Whether logout requests and logout responses received by this SP should be validated. The default is `false`. `RegistrationInfo` @@ -338,7 +340,7 @@ Options : A file with a certificate _and_ private key that should be used when issuing SOAP requests from this SP. If this option isn't specified, the SP private key and certificate will be used. -: This option can also be set to `FALSE`, in which case no client certificate will be used. +: This option can also be set to `false`, in which case no client certificate will be used. `saml.SOAPClient.privatekey_pass` : The passphrase of the privatekey in `saml.SOAPClient.certificate`. @@ -395,7 +397,7 @@ Options `WantAssertionsSigned` -: Whether assertions received by this SP must be signed. The default value is `FALSE`. +: Whether assertions received by this SP must be signed. The default value is `false`. The value set for this option will be used to set the `WantAssertionsSigned` attribute of the `SPSSODescriptor` element in the exported SAML 2.0 metadata. @@ -435,8 +437,8 @@ Here we will list some examples for this authentication source. 'certificate' => 'example.crt', 'privatekey' => 'example.key', 'privatekey_pass' => 'secretpassword', - 'redirect.sign' => TRUE, - 'redirect.validate' => TRUE, + 'redirect.sign' => true, + 'redirect.validate' => true, ], @@ -470,7 +472,6 @@ Here we will list some examples for this authentication source. 'saml:SP', 'acs.Bindings' => [ 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', - 'urn:oasis:names:tc:SAML:1.0:profiles:browser-post', ], ], diff --git a/modules/saml/lib/Auth/Process/ExpectedAuthnContextClassRef.php b/modules/saml/lib/Auth/Process/ExpectedAuthnContextClassRef.php index bc9fdef1b1da061a5132913945308ecb799fd210..2ffd641a6695540280fa9b5c842a93311d743f61 100644 --- a/modules/saml/lib/Auth/Process/ExpectedAuthnContextClassRef.php +++ b/modules/saml/lib/Auth/Process/ExpectedAuthnContextClassRef.php @@ -17,13 +17,13 @@ use SimpleSAML\Utils; * * Example configuration: * - * 91 => array( + * 91 => [ * 'class' => 'saml:ExpectedAuthnContextClassRef', - * 'accepted' => array( + * 'accepted' => [ * 'urn:oasis:names:tc:SAML:2.0:post:ac:classes:nist-800-63:3', * 'urn:oasis:names:tc:SAML:2.0:ac:classes:Password', - * ), - * ), + * ], + * ], * * @package SimpleSAMLphp */ diff --git a/modules/saml/lib/Auth/Process/FilterScopes.php b/modules/saml/lib/Auth/Process/FilterScopes.php index 9748a9c614208d0912d364d42ea10ad4061f487f..b0fb75237d7fa7e4384af1bdc89fb6ad9436b655 100644 --- a/modules/saml/lib/Auth/Process/FilterScopes.php +++ b/modules/saml/lib/Auth/Process/FilterScopes.php @@ -25,7 +25,6 @@ class FilterScopes extends ProcessingFilter 'eduPersonPrincipalName' ]; - /** * Constructor for the processing filter. * @@ -41,7 +40,6 @@ class FilterScopes extends ProcessingFilter } } - /** * This method applies the filter, removing any values * @@ -50,53 +48,45 @@ class FilterScopes extends ProcessingFilter public function process(array &$request): void { $src = $request['Source']; - if (!count($this->scopedAttributes)) { - // paranoia, should never happen - Logger::warning('No scoped attributes configured.'); - return; - } + $validScopes = []; + $host = ''; if (array_key_exists('scope', $src) && is_array($src['scope']) && !empty($src['scope'])) { $validScopes = $src['scope']; + } else { + $ep = Utils\Config\Metadata::getDefaultEndpoint($request['Source']['SingleSignOnService']); + $host = parse_url($ep['Location'], PHP_URL_HOST) ?? ''; } - $ep = Utils\Config\Metadata::getDefaultEndpoint($request['Source']['SingleSignOnService']); - if ($ep !== null) { - foreach ($this->scopedAttributes as $attribute) { - if (!isset($request['Attributes'][$attribute])) { - continue; - } + foreach ($this->scopedAttributes as $attribute) { + if (!isset($request['Attributes'][$attribute])) { + continue; + } - $values = $request['Attributes'][$attribute]; - $newValues = []; - foreach ($values as $value) { - $loc = $ep['Location']; - $host = parse_url($loc, PHP_URL_HOST); - if ($host === null) { - $host = ''; - } - $value_a = explode('@', $value, 2); - if (count($value_a) < 2) { - $newValues[] = $value; - continue; // there's no scope - } - $scope = $value_a[1]; - if (in_array($scope, $validScopes, true)) { - $newValues[] = $value; - } elseif (strpos($host, $scope) === strlen($host) - strlen($scope)) { - $newValues[] = $value; - } else { - Logger::warning("Removing value '$value' for attribute '$attribute'. Undeclared scope."); - } + $values = $request['Attributes'][$attribute]; + $newValues = []; + foreach ($values as $value) { + @list(, $scope) = explode('@', $value, 2); + if ($scope === null) { + $newValues[] = $value; + continue; // there's no scope } - if (empty($newValues)) { - Logger::warning("No suitable values for attribute '$attribute', removing it."); - unset($request['Attributes'][$attribute]); // remove empty attributes + if (in_array($scope, $validScopes, true)) { + $newValues[] = $value; + } elseif (strpos($host, $scope) === strlen($host) - strlen($scope)) { + $newValues[] = $value; } else { - $request['Attributes'][$attribute] = $newValues; + Logger::warning("Removing value '$value' for attribute '$attribute'. Undeclared scope."); } } + + if (empty($newValues)) { + Logger::warning("No suitable values for attribute '$attribute', removing it."); + unset($request['Attributes'][$attribute]); // remove empty attributes + } else { + $request['Attributes'][$attribute] = $newValues; + } } } } diff --git a/modules/saml/lib/Auth/Process/SQLPersistentNameID.php b/modules/saml/lib/Auth/Process/SQLPersistentNameID.php index 60e8a0223335979768ba38a82fe34c908f7887d9..d166f7533229e13105e0316729bd0714a2d8a664 100644 --- a/modules/saml/lib/Auth/Process/SQLPersistentNameID.php +++ b/modules/saml/lib/Auth/Process/SQLPersistentNameID.php @@ -62,7 +62,7 @@ class SQLPersistentNameID extends BaseNameIDGenerator * * @throws \SimpleSAML\Error\Exception If the 'attribute' option is not specified. */ - public function __construct(array $config, $reserved) + public function __construct(array &$config, $reserved) { parent::__construct($config, $reserved); diff --git a/modules/saml/lib/Auth/Source/SP.php b/modules/saml/lib/Auth/Source/SP.php index e82405c4fa9a4dcf8620a7c5f1c525f158c9891d..f09b44c1a52108fe84a8082804a82e2bde3a9642 100644 --- a/modules/saml/lib/Auth/Source/SP.php +++ b/modules/saml/lib/Auth/Source/SP.php @@ -63,7 +63,7 @@ class SP extends \SimpleSAML\Auth\Source * * @var string[] */ - private array $protocols = []; + private array $protocols = [Constants::NS_SAMLP]; /** @@ -138,7 +138,7 @@ class SP extends \SimpleSAML\Auth\Source ]; // add NameIDPolicy - if ($this->metadata->hasValue('NameIDValue')) { + if ($this->metadata->hasValue('NameIDPolicy')) { $format = $this->metadata->getValue('NameIDPolicy'); if (is_array($format)) { $metadata['NameIDFormat'] = Configuration::loadFromArray($format)->getString( @@ -329,6 +329,11 @@ class SP extends \SimpleSAML\Auth\Source */ private function getACSEndpoints(): array { + // If a list of endpoints is specified in config, take that at face value + if ($this->metadata->hasValue('AssertionConsumerService')) { + return $this->metadata->getArray('AssertionConsumerService'); + } + $endpoints = []; $default = [ Constants::BINDING_HTTP_POST, @@ -347,18 +352,12 @@ class SP extends \SimpleSAML\Auth\Source 'Binding' => Constants::BINDING_HTTP_POST, 'Location' => Module::getModuleURL('saml/sp/saml2-acs.php/' . $this->getAuthId()), ]; - if (!in_array(Constants::NS_SAMLP, $this->protocols, true)) { - $this->protocols[] = Constants::NS_SAMLP; - } break; case Constants::BINDING_HTTP_ARTIFACT: $acs = [ 'Binding' => Constants::BINDING_HTTP_ARTIFACT, 'Location' => Module::getModuleURL('saml/sp/saml2-acs.php/' . $this->getAuthId()), ]; - if (!in_array(Constants::NS_SAMLP, $this->protocols, true)) { - $this->protocols[] = Constants::NS_SAMLP; - } break; case Constants::BINDING_HOK_SSO: $acs = [ @@ -366,12 +365,10 @@ class SP extends \SimpleSAML\Auth\Source 'Location' => Module::getModuleURL('saml/sp/saml2-acs.php/' . $this->getAuthId()), 'hoksso:ProtocolBinding' => Constants::BINDING_HTTP_REDIRECT, ]; - if (!in_array(Constants::NS_SAMLP, $this->protocols, true)) { - $this->protocols[] = Constants::NS_SAMLP; - } break; default: - $acs = []; + Logger::warning('Unknown acs.Binding value specified, ignoring: ' . $service); + continue 2; } $acs['index'] = $index; $endpoints[] = $acs; @@ -397,7 +394,8 @@ class SP extends \SimpleSAML\Auth\Source Constants::BINDING_SOAP, ] ); - $location = Module::getModuleURL('saml/sp/saml2-logout.php/' . $this->getAuthId()); + $defaultLocation = Module::getModuleURL('saml/sp/saml2-logout.php/' . $this->getAuthId()); + $location = $this->metadata->getString('SingleLogoutServiceLocation', $defaultLocation); $endpoints = []; foreach ($bindings as $binding) { diff --git a/modules/saml/www/sp/metadata.php b/modules/saml/www/sp/metadata.php index 3b4e847eb5759108e229c74e8cdcd1a36fb52630..603209c53301c7b4ed03a3a40c3e211ffcee43fc 100644 --- a/modules/saml/www/sp/metadata.php +++ b/modules/saml/www/sp/metadata.php @@ -36,239 +36,11 @@ if (!($source instanceof Module\saml\Auth\Source\SP)) { $entityId = $source->getEntityId(); $spconfig = $source->getMetadata(); +$metaArray20 = $source->getHostedMetadata(); $store = Store::getInstance(); -$metaArray20 = []; - -$slosvcdefault = [ - Constants::BINDING_HTTP_REDIRECT, - Constants::BINDING_SOAP, -]; - -$slob = $spconfig->getArray('SingleLogoutServiceBinding', $slosvcdefault); -$slol = Module::getModuleURL('saml/sp/saml2-logout.php/' . $sourceId); - -foreach ($slob as $binding) { - if ($binding == Constants::BINDING_SOAP && !($store instanceof Store\SQL)) { - // we cannot properly support SOAP logout - continue; - } - $metaArray20['SingleLogoutService'][] = [ - 'Binding' => $binding, - 'Location' => $spconfig->getString('SingleLogoutServiceLocation', $slol), - ]; -} - -$assertionsconsumerservicesdefault = [ - 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', - 'urn:oasis:names:tc:SAML:1.0:profiles:browser-post', - 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact', - 'urn:oasis:names:tc:SAML:1.0:profiles:artifact-01', -]; - -if ($spconfig->getString('ProtocolBinding', '') == 'urn:oasis:names:tc:SAML:2.0:profiles:holder-of-key:SSO:browser') { - $assertionsconsumerservicesdefault[] = 'urn:oasis:names:tc:SAML:2.0:profiles:holder-of-key:SSO:browser'; -} - -$assertionsconsumerservices = $spconfig->getArray('acs.Bindings', $assertionsconsumerservicesdefault); - -$index = 0; -$eps = []; -$supported_protocols = []; -foreach ($assertionsconsumerservices as $services) { - $acsArray = ['index' => $index]; - switch ($services) { - case 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST': - $acsArray['Binding'] = Constants::BINDING_HTTP_POST; - $acsArray['Location'] = Module::getModuleURL('saml/sp/saml2-acs.php/' . $sourceId); - if (!in_array(Constants::NS_SAMLP, $supported_protocols, true)) { - $supported_protocols[] = Constants::NS_SAMLP; - } - break; - case 'urn:oasis:names:tc:SAML:1.0:profiles:browser-post': - $acsArray['Binding'] = 'urn:oasis:names:tc:SAML:1.0:profiles:browser-post'; - $acsArray['Location'] = Module::getModuleURL('saml/sp/saml1-acs.php/' . $sourceId); - if (!in_array('urn:oasis:names:tc:SAML:1.1:protocol', $supported_protocols, true)) { - $supported_protocols[] = 'urn:oasis:names:tc:SAML:1.1:protocol'; - } - break; - case 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact': - $acsArray['Binding'] = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact'; - $acsArray['Location'] = Module::getModuleURL('saml/sp/saml2-acs.php/' . $sourceId); - if (!in_array(Constants::NS_SAMLP, $supported_protocols, true)) { - $supported_protocols[] = Constants::NS_SAMLP; - } - break; - case 'urn:oasis:names:tc:SAML:1.0:profiles:artifact-01': - $acsArray['Binding'] = 'urn:oasis:names:tc:SAML:1.0:profiles:artifact-01'; - $acsArray['Location'] = Module::getModuleURL( - 'saml/sp/saml1-acs.php/' . $sourceId . '/artifact' - ); - if (!in_array('urn:oasis:names:tc:SAML:1.1:protocol', $supported_protocols, true)) { - $supported_protocols[] = 'urn:oasis:names:tc:SAML:1.1:protocol'; - } - break; - case 'urn:oasis:names:tc:SAML:2.0:profiles:holder-of-key:SSO:browser': - $acsArray['Binding'] = 'urn:oasis:names:tc:SAML:2.0:profiles:holder-of-key:SSO:browser'; - $acsArray['Location'] = Module::getModuleURL('saml/sp/saml2-acs.php/' . $sourceId); - $acsArray['hoksso:ProtocolBinding'] = Constants::BINDING_HTTP_REDIRECT; - if (!in_array(Constants::NS_SAMLP, $supported_protocols, true)) { - $supported_protocols[] = Constants::NS_SAMLP; - } - break; - } - $eps[] = $acsArray; - $index++; -} - -$metaArray20['AssertionConsumerService'] = $spconfig->getArray('AssertionConsumerService', $eps); - -$keys = []; -$cryptoUtils = new Utils\Crypto(); -$certInfo = $cryptoUtils->loadPublicKey($spconfig, false, 'new_'); -if ($certInfo !== null && array_key_exists('certData', $certInfo)) { - $hasNewCert = true; - - $certData = $certInfo['certData']; - - $keys[] = [ - 'type' => 'X509Certificate', - 'signing' => true, - 'encryption' => true, - 'X509Certificate' => $certInfo['certData'], - ]; -} else { - $hasNewCert = false; -} - -$certInfo = $cryptoUtils->loadPublicKey($spconfig); -if ($certInfo !== null && array_key_exists('certData', $certInfo)) { - $certData = $certInfo['certData']; - - $keys[] = [ - 'type' => 'X509Certificate', - 'signing' => true, - 'encryption' => ($hasNewCert ? false : true), - 'X509Certificate' => $certInfo['certData'], - ]; -} else { - $certData = null; -} - -$format = $spconfig->getValue('NameIDPolicy', null); -if ($format !== null) { - if (is_array($format)) { - $metaArray20['NameIDFormat'] = Configuration::loadFromArray($format)->getString( - 'Format', - Constants::NAMEID_TRANSIENT - ); - } elseif (is_string($format)) { - $metaArray20['NameIDFormat'] = $format; - } -} - -$name = $spconfig->getLocalizedString('name', null); -$attributes = $spconfig->getArray('attributes', []); - -if ($name !== null && !empty($attributes)) { - $metaArray20['name'] = $name; - $metaArray20['attributes'] = $attributes; - $metaArray20['attributes.required'] = $spconfig->getArray('attributes.required', []); - - if (empty($metaArray20['attributes.required'])) { - unset($metaArray20['attributes.required']); - } - - $description = $spconfig->getArray('description', null); - if ($description !== null) { - $metaArray20['description'] = $description; - } - - $nameFormat = $spconfig->getString('attributes.NameFormat', null); - if ($nameFormat !== null) { - $metaArray20['attributes.NameFormat'] = $nameFormat; - } - - if ($spconfig->hasValue('attributes.index')) { - $metaArray20['attributes.index'] = $spconfig->getInteger('attributes.index', 0); - } - - if ($spconfig->hasValue('attributes.isDefault')) { - $metaArray20['attributes.isDefault'] = $spconfig->getBoolean('attributes.isDefault', false); - } -} - -// add organization info -$orgName = $spconfig->getLocalizedString('OrganizationName', null); -if ($orgName !== null) { - $metaArray20['OrganizationName'] = $orgName; - - $metaArray20['OrganizationDisplayName'] = $spconfig->getLocalizedString('OrganizationDisplayName', null); - if ($metaArray20['OrganizationDisplayName'] === null) { - $metaArray20['OrganizationDisplayName'] = $orgName; - } - - $metaArray20['OrganizationURL'] = $spconfig->getLocalizedString('OrganizationURL', null); - if ($metaArray20['OrganizationURL'] === null) { - throw new Error\Exception('If OrganizationName is set, OrganizationURL must also be set.'); - } -} - -if ($spconfig->hasValue('contacts')) { - $contacts = $spconfig->getArray('contacts'); - foreach ($contacts as $contact) { - $metaArray20['contacts'][] = Utils\Config\Metadata::getContact($contact); - } -} - -// add technical contact -$email = $config->getString('technicalcontact_email', 'na@example.org'); -if ($email && $email !== 'na@example.org') { - $techcontact = [ - 'emailAddress' => $email, - 'name' => $config->getString('technicalcontact_name', null), - 'contactType' => 'technical' - ]; - $metaArray20['contacts'][] = Utils\Config\Metadata::getContact($techcontact); -} - -// add certificate -if (count($keys) === 1) { - $metaArray20['certData'] = $keys[0]['X509Certificate']; -} elseif (count($keys) > 1) { - $metaArray20['keys'] = $keys; -} - -// add EntityAttributes extension -if ($spconfig->hasValue('EntityAttributes')) { - $metaArray20['EntityAttributes'] = $spconfig->getArray('EntityAttributes'); -} - -// add UIInfo extension -if ($spconfig->hasValue('UIInfo')) { - $metaArray20['UIInfo'] = $spconfig->getArray('UIInfo'); -} - -// add RegistrationInfo extension -if ($spconfig->hasValue('RegistrationInfo')) { - $metaArray20['RegistrationInfo'] = $spconfig->getArray('RegistrationInfo'); -} - -// add signature options -if ($spconfig->hasValue('WantAssertionsSigned')) { - $metaArray20['saml20.sign.assertion'] = $spconfig->getBoolean('WantAssertionsSigned'); -} -if ($spconfig->hasValue('redirect.sign')) { - $metaArray20['redirect.validate'] = $spconfig->getBoolean('redirect.sign'); -} elseif ($spconfig->hasValue('sign.authnrequest')) { - $metaArray20['validate.authnrequest'] = $spconfig->getBoolean('sign.authnrequest'); -} - -$metaArray20['metadata-set'] = 'saml20-sp-remote'; -$metaArray20['entityid'] = $entityId; - $metaBuilder = new Metadata\SAMLBuilder($entityId); -$metaBuilder->addMetadataSP20($metaArray20, $supported_protocols); +$metaBuilder->addMetadataSP20($metaArray20, $source->getSupportedProtocols()); $metaBuilder->addOrganizationInfo($metaArray20); $xml = $metaBuilder->getEntityDescriptorText(); diff --git a/tests/lib/SimpleSAML/Metadata/SAMLBuilderTest.php b/tests/lib/SimpleSAML/Metadata/SAMLBuilderTest.php index 2998a753a8d49a0dd102b3c4d57ca300edbf90bd..42e254619dffbfaee1724750effa8be53e487189 100644 --- a/tests/lib/SimpleSAML/Metadata/SAMLBuilderTest.php +++ b/tests/lib/SimpleSAML/Metadata/SAMLBuilderTest.php @@ -240,4 +240,44 @@ class SAMLBuilderTest extends TestCase $entityDescriptorXml ); } + + /** + * Test custom metadata extension (saml:Extensions). + */ + public function testCustomMetadataExtension(): void + { + $entityId = 'https://entity.example.com/id'; + $set = 'saml20-idp-remote'; + + $dom = \SAML2\DOMDocumentFactory::create(); + $republishRequest = $dom->createElementNS( + 'http://eduid.cz/schema/metadata/1.0', + 'eduidmd:RepublishRequest' + ); + $republishTargetContent = 'http://edugain.org/'; + $republishTarget = $dom->createElementNS( + 'http://eduid.cz/schema/metadata/1.0', + 'eduidmd:RepublishTarget', + $republishTargetContent + ); + $republishRequest->appendChild($republishTarget); + $ext = [new \SAML2\XML\Chunk($republishRequest)]; + + $metadata = [ + 'entityid' => $entityId, + 'name' => ['en' => 'Test IdP'], + 'metadata-set' => $set, + 'saml:Extensions' => $ext, + ]; + + $samlBuilder = new SAMLBuilder($entityId); + $samlBuilder->addMetadata($set, $metadata); + + $idpDesc = $samlBuilder->getEntityDescriptor(); + $rt = $idpDesc->getElementsByTagNameNS('http://eduid.cz/schema/metadata/1.0', 'RepublishTarget'); + + /** @var \DOMElement $rt1 */ + $rt1 = $rt->item(0); + $this->assertEquals($republishTargetContent, $rt1->textContent); + } } diff --git a/tests/modules/exampleauth/lib/Controller/ExampleAuthTest.php b/tests/modules/exampleauth/lib/Controller/ExampleAuthTest.php new file mode 100644 index 0000000000000000000000000000000000000000..3772184d900b4c32c7498cb93d84db355d283028 --- /dev/null +++ b/tests/modules/exampleauth/lib/Controller/ExampleAuthTest.php @@ -0,0 +1,245 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\exampleauth\Controller; + +use PHPUnit\Framework\TestCase; +use SimpleSAML\Auth; +use SimpleSAML\Configuration; +use SimpleSAML\Error; +use SimpleSAML\HTTP\RunnableResponse; +use SimpleSAML\Module\exampleauth\Controller; +use SimpleSAML\Session; +use SimpleSAML\XHTML\Template; +use Symfony\Component\HttpFoundation\Request; + +/** + * Set of tests for the controllers in the "exampleauth" module. + * + * @covers \SimpleSAML\Module\exampleauth\Controller\ExampleAuth + */ +class ExampleAuthTest extends TestCase +{ + /** @var \SimpleSAML\Configuration */ + protected Configuration $config; + + /** @var \SimpleSAML\Session */ + protected Session $session; + + + /** + * Set up for each test. + */ + protected function setUp(): void + { + parent::setUp(); + + $this->config = Configuration::loadFromArray( + [ + 'module.enable' => ['exampleauth' => true], + ], + '[ARRAY]', + 'simplesaml' + ); + + $this->session = Session::getSessionFromRequest(); + + Configuration::setPreLoadedConfig($this->config, 'config.php'); + } + + + /** + * Test that accessing the authpage-endpoint without ReturnTo parameter throws an exception + * + * @return void + */ + public function testAuthpageNoReturnTo(): void + { + $request = Request::create( + '/authpage', + 'GET', + ['NoReturnTo' => 'Limbo'], + ); + + $c = new Controller\ExampleAuth($this->config, $this->session); + + $this->expectException(Error\Exception::class); + $this->expectExceptionMessage('Missing ReturnTo parameter.'); + + $c->authpage($request); + } + + + /** + * Test that accessing the authpage-endpoint without a valid ReturnTo parameter throws an exception + * + * @return void + */ + public function testAuthpageInvalidReturnTo(): void + { + $request = Request::create( + '/authpage', + 'GET', + ['ReturnTo' => 'SomeBogusValue'], + ); + + $c = new Controller\ExampleAuth($this->config, $this->session); + + $this->expectException(Error\Exception::class); + $this->expectExceptionMessage('Invalid ReturnTo URL for this example.'); + + $c->authpage($request); + } + + + /** + * Test that accessing the authpage-endpoint using GET-method show a login-screen + * + * @return void + */ + public function testAuthpageGetMethod(): void + { + $request = Request::create( + '/authpage', + 'GET', + ['ReturnTo' => 'State=/'], + ); + + $c = new Controller\ExampleAuth($this->config, $this->session); + $c->setAuthState(new class () extends Auth\State { + public static function loadState(string $id, string $stage, bool $allowMissing = false): ?array + { + return []; + } + }); + + $response = $c->authpage($request); + $this->assertTrue($response->isSuccessful()); + $this->assertInstanceOf(Template::class, $response); + } + + + /** + * Test that accessing the authpage-endpoint using POST-method and using the correct password triggers a redirect + * + * @return void + */ + public function testAuthpagePostMethodCorrectPassword(): void + { + $this->markTestSkipped('Needs debugging'); + + $request = Request::create( + '/authpage', + 'POST', + ['ReturnTo' => 'State=/', 'username' => 'student', 'password' => 'student'], + ); + + $c = new Controller\ExampleAuth($this->config, $this->session); + $c->setAuthState(new class () extends Auth\State { + public static function loadState(string $id, string $stage, bool $allowMissing = false): ?array + { + return []; + } + }); + + $response = $c->authpage($request); + $this->assertTrue($response->isSuccessful()); + $this->assertInstanceOf(RunnableResponse::class, $response); + } + + + /** + * Test that accessing the authpage-endpoint using POST-method and an incorrect password shows the login-screen again + * + * @return void + */ + public function testAuthpagePostMethodIncorrectPassword(): void + { + $request = Request::create( + '/authpage', + 'POST', + ['ReturnTo' => 'State=/', 'username' => 'user', 'password' => 'something stupid'], + ); + + $c = new Controller\ExampleAuth($this->config, $this->session); + $c->setAuthState(new class () extends Auth\State { + public static function loadState(string $id, string $stage, bool $allowMissing = false): ?array + { + return []; + } + }); + + $response = $c->authpage($request); + $this->assertTrue($response->isSuccessful()); + $this->assertInstanceOf(Template::class, $response); + } + + + /** + * Test that accessing the resume-endpoint leads to a redirect + * + * @return void + */ + public function testResume(): void + { + $request = Request::create( + '/resume', + 'GET', + ); + + $c = new Controller\ExampleAuth($this->config, $this->session); + + $response = $c->resume($request); + $this->assertTrue($response->isSuccessful()); + $this->assertInstanceOf(RunnableResponse::class, $response); + } + + + /** + * Test that accessing the redirecttest-endpoint leads to a redirect + * + * @return void + */ + public function testRedirect(): void + { + $request = Request::create( + '/redirecttest', + 'GET', + ['StateId' => 'someState'] + ); + + $c = new Controller\ExampleAuth($this->config, $this->session); + $c->setAuthState(new class () extends Auth\State { + public static function loadState(string $id, string $stage, bool $allowMissing = false): ?array + { + return []; + } + }); + + $response = $c->redirecttest($request); + $this->assertTrue($response->isSuccessful()); + $this->assertInstanceOf(RunnableResponse::class, $response); + } + + + /** + * Test that accessing the redirecttest-endpoint without StateId leads to an exception + * + * @return void + */ + public function testRedirectMissingStateId(): void + { + $request = Request::create( + '/redirecttest', + 'GET', + ); + + $c = new Controller\ExampleAuth($this->config, $this->session); + + $this->expectException(Error\BadRequest::class); + $this->expectExceptionMessage('Missing required StateId query parameter.'); + + $c->redirecttest($request); + } +} diff --git a/tests/modules/saml/lib/Auth/Process/FilterScopesTest.php b/tests/modules/saml/lib/Auth/Process/FilterScopesTest.php index 724eebe57ac0aa6927a05b4581306d7e2bed4f7a..2730453c3bb554b9f87f97dbe12e3d086a7a8df7 100644 --- a/tests/modules/saml/lib/Auth/Process/FilterScopesTest.php +++ b/tests/modules/saml/lib/Auth/Process/FilterScopesTest.php @@ -68,19 +68,12 @@ class FilterScopesTest extends TestCase $result = $this->processFilter($config, $request); $this->assertEquals($request['Attributes'], $result['Attributes']); - // test implicit scope - $request['Attributes'] = [ - 'eduPersonPrincipalName' => ['jdoe@example.org'], - ]; - $result = $this->processFilter($config, $request); - $this->assertEquals($request['Attributes'], $result['Attributes']); - // test alternative attributes $config['attributes'] = [ 'mail', ]; $request['Attributes'] = [ - 'mail' => ['john.doe@example.org'], + 'mail' => ['john.doe@example.com'], ]; $result = $this->processFilter($config, $request); $this->assertEquals($request['Attributes'], $result['Attributes']); @@ -91,6 +84,35 @@ class FilterScopesTest extends TestCase $this->assertEquals($request['Attributes'], $result['Attributes']); } + /** + * Test implict scope matching on IdP hostname + */ + public function testImplicitScopes(): void + { + $config = []; + $request = [ + 'Source' => [ + 'SingleSignOnService' => [ + [ + 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + 'Location' => 'https://example.org/saml2/idp/SSOService.php', + ], + ], + ], + 'Attributes' => [ + 'eduPersonPrincipalName' => ['jdoe@example.org'], + ], + ]; + + $result = $this->processFilter($config, $request); + $this->assertEquals($request['Attributes'], $result['Attributes']); + + $request['Attributes'] = [ + 'eduPersonPrincipalName' => ['jdoe@example.com'], + ]; + $result = $this->processFilter($config, $request); + $this->assertEquals([], $result['Attributes']); + } /** * Test invalid scopes. @@ -137,5 +159,64 @@ class FilterScopesTest extends TestCase ]; $result = $this->processFilter($config, $request); $this->assertEquals($request['Attributes'], $result['Attributes']); + + } + + /** + * Test that implicit matching is not done when explicit scopes present + */ + public function testNoImplicitMatchingWhenExplicitScopes(): void + { + // test declared scopes + $config = []; + $request = [ + 'Source' => [ + 'SingleSignOnService' => [ + [ + 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + 'Location' => 'https://example.org/saml2/idp/SSOService.php', + ], + ], + 'scope' => [ + 'example.com', + 'example.net', + ], + ], + 'Attributes' => [ + 'eduPersonPrincipalName' => ['jdoe@example.org'], + ], + ]; + $result = $this->processFilter($config, $request); + $this->assertEquals([], $result['Attributes']); + } + + /** + * Test that the scope is considered to be the part after the first @ sign + */ + public function testAttributeValueMultipleAt(): void + { + $config = []; + $request = [ + 'Source' => [ + 'SingleSignOnService' => [ + [ + 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + 'Location' => 'https://example.org/saml2/idp/SSOService.php', + ], + ], + 'scope' => [ + 'example.com', + ], + ], + 'Attributes' => [ + 'eduPersonPrincipalName' => ['jdoe@gmail.com@example.com'], + ], + ]; + $result = $this->processFilter($config, $request); + $this->assertEquals([], $result['Attributes']); + + $request['Source']['scope'] = ['gmail.com@example.com']; + $result = $this->processFilter($config, $request); + $this->assertEquals($request['Attributes'], $result['Attributes']); } } diff --git a/tests/modules/saml/lib/Auth/Source/SPTest.php b/tests/modules/saml/lib/Auth/Source/SPTest.php index d748b61f6d22947b256aa95824eb71c30436cec4..2a048dd074c1517cdb69556098a5d147d26546f1 100644 --- a/tests/modules/saml/lib/Auth/Source/SPTest.php +++ b/tests/modules/saml/lib/Auth/Source/SPTest.php @@ -10,6 +10,7 @@ use SAML2\AuthnRequest; use SAML2\Constants; use SAML2\Utils; use SimpleSAML\Configuration; +use SimpleSAML\Error\Exception; use SimpleSAML\Module\saml\Error\NoAvailableIDP; use SimpleSAML\Module\saml\Error\NoSupportedIDP; use SimpleSAML\Test\Metadata\MetaDataStorageSourceTest; @@ -24,6 +25,21 @@ use SimpleSAML\Test\Utils\SpTester; */ class SPTest extends ClearStateTestCase { + /** @var string */ + private const SECURITY = 'vendor/simplesamlphp/xml-security/tests/resources'; + + /** @var string */ + public const CERT_KEY = '../' . self::SECURITY . '/certificates/rsa-pem/selfsigned.simplesamlphp.org.key'; + + /** @var string */ + public const CERT_PUBLIC = '../' . self::SECURITY . '/certificates/rsa-pem/selfsigned.simplesamlphp.org.crt'; + + /** @var string */ + public const CERT_OTHER_KEY = '../' . self::SECURITY . '/certificates/rsa-pem/other.simplesamlphp.org.key'; + + /** @var string */ + public const CERT_OTHER_PUBLIC = '../' . self::SECURITY . '/certificates/rsa-pem/other.simplesamlphp.org.crt'; + /** @var \SimpleSAML\Configuration|null $idpMetadata */ private ?Configuration $idpMetadata = null; @@ -422,8 +438,844 @@ class SPTest extends ClearStateTestCase $as->authenticate($state); } + /** + * Basic test for the hosted metadata generation in a default config + */ + public function testMetadataHostedBasicConfig(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + $config = []; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertIsArray($md); + $this->assertEquals('saml20-sp-remote', $md['metadata-set']); + $this->assertEquals('http://localhost/simplesaml/module.php/saml/sp/metadata.php/' . $spId, $md['entityid']); + $this->assertArrayHasKey('SingleLogoutService', $md); + $this->assertIsArray($md['SingleLogoutService']); + $this->assertArrayHasKey('AssertionConsumerService', $md); + $this->assertIsArray($md['AssertionConsumerService']); + foreach($md['AssertionConsumerService'] as $acs) { + $this->assertEquals('http://localhost/simplesaml/module.php/saml/sp/saml2-acs.php/' . $spId, $acs['Location']); + $this->assertStringStartsWith('urn:oasis:names:tc:SAML:2.0:bindings', $acs['Binding']); + $this->assertIsInt($acs['index']); + } + } + + /** + * Test for the hosted metadata generation with a custom entityID + */ + public function testMetadataHostedSetEntityId(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + $config = ['entityID' => 'urn:example:mysp:001']; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertEquals('urn:example:mysp:001', $md['entityid']); + } + + /** + * Contacts in SP hosted config appear in metadata + */ + public function testMetadataHostedContacts(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + $config = ['contacts' => [ + [ + 'contactType' => 'other', + 'emailAddress' => 'csirt@example.com', + 'surName' => 'CSIRT', + 'telephoneNumber' => '+31SECOPS', + 'company' => 'Acme Inc', + 'attributes' => [ + 'xmlns:remd' => 'http://refeds.org/metadata', + 'remd:contactType' => 'http://refeds.org/metadata/contactType/security', + ], + ], + [ + 'contactType' => 'administrative', + 'emailAddress' => 'j.doe@example.edu', + 'givenName' => 'Jane', + 'surName' => 'Doe', + ], + ]]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertArrayHasKey('contacts', $md); + $this->assertIsArray($md['contacts']); + $this->assertCount(2, $md['contacts']); + + $contacts = $md['contacts']; + $contact = $md['contacts'][0]; + + $this->assertIsArray($contact); + $this->assertEquals('other', $contact['contactType']); + $this->assertEquals('CSIRT', $contact['surName']); + $this->assertArrayNotHasKey('givenName', $contact); + $this->assertEquals('+31SECOPS', $contact['telephoneNumber']); + $this->assertEquals('Acme Inc', $contact['company']); + $this->assertIsArray($contact['attributes']); + $attrs = ['xmlns:remd' => 'http://refeds.org/metadata', 'remd:contactType' => 'http://refeds.org/metadata/contactType/security']; + $this->assertEquals($attrs, $contact['attributes']); + + $contact = $md['contacts'][1]; + $this->assertIsArray($contact); + $this->assertEquals('administrative', $contact['contactType']); + $this->assertEquals('j.doe@example.edu', $contact['emailAddress']); + $this->assertArrayNotHasKey('attributes', $contact); + } + + /** + * A globally set tech contact also appears in SP hosted metadata + */ + public function testMetadataHostedContactsIncludesGlobalTechContact(): void + { + Configuration::loadFromArray([ + 'technicalcontact_email' => 'someone.somewhere@example.org', + 'technicalcontact_name' => 'Someone von Somewhere', + ], '[ARRAY]', 'simplesaml'); + + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + $config = ['contacts' => [ + [ + 'contactType' => 'technical', + 'emailAddress' => 'j.doe@example.edu', + 'givenName' => 'Jane', + 'surName' => 'Doe', + ], + ]]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertArrayHasKey('contacts', $md); + $this->assertIsArray($md['contacts']); + $this->assertCount(2, $md['contacts']); + + $contacts = $md['contacts']; + $contact = $md['contacts'][0]; + + $this->assertIsArray($contact); + $this->assertEquals('technical', $contact['contactType']); + $this->assertEquals('Doe', $contact['surName']); + + $contact = $md['contacts'][1]; + $this->assertIsArray($contact); + $this->assertEquals('technical', $contact['contactType']); + $this->assertEquals('someone.somewhere@example.org', $contact['emailAddress']); + $this->assertEquals('Someone von Somewhere', $contact['givenName']); + $this->assertArrayNotHasKey('surName', $contact); + } + + /** + * The special value na@example.org global tech contact is not included in SP metadata + */ + public function testMetadataHostedContactsSkipsNAGlobalTechContact(): void + { + Configuration::loadFromArray([ + 'technicalcontact_email' => 'na@example.org', + 'technicalcontact_name' => 'Someone von Somewhere', + ], '[ARRAY]', 'simplesaml'); + + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + $config = ['contacts' => [ + [ + 'contactType' => 'technical', + 'emailAddress' => 'j.doe@example.edu', + 'surName' => 'Doe', + ], + ]]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertCount(1, $md['contacts']); + $this->assertEquals('j.doe@example.edu', $md['contacts'][0]['emailAddress']); + } + + /** + * Contacts in SP hosted of unknown type throws Exceptiona + */ + public function testMetadataHostedContactsUnknownTypeThrowsException(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + $config = ['contacts' => [ + [ + 'contactType' => 'anything', + 'emailAddress' => 'j.doe@example.edu', + 'givenName' => 'Jane', + 'surName' => 'Doe', + ], + ]]; + $as = new SpTester($info, $config); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"contactType" is mandatory and must be one of'); + + $md = $as->getHostedMetadata(); + } + + /** + * SP acs.Bindings option overrides default bindigs + */ + public function testMetadataHostedAcsBindingsOption(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + $config = ['contacts' => + [ + [ + 'contactType' => 'administrative', + 'emailAddress' => 'j.doe@example.edu', + 'givenName' => 'Jane', + 'surName' => 'Doe', + ], + ], + 'acs.Bindings' => ['urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'], + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertCount(1, $md['AssertionConsumerService']); + $this->assertEquals('urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', $md['AssertionConsumerService'][0]['Binding']); + } + + /** + * SP acs.Bindings option with unsupported value should be skipped + */ + public function testMetadataHostedAcsBindingsUnknownValueIsSkipped(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + $config = ['contacts' => + [ + [ + 'contactType' => 'administrative', + 'emailAddress' => 'j.doe@example.edu', + 'givenName' => 'Jane', + 'surName' => 'Doe', + ], + ], + 'acs.Bindings' => ['urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', 'urn:this:doesnotexist'], + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertCount(1, $md['AssertionConsumerService']); + $this->assertEquals('urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', $md['AssertionConsumerService'][0]['Binding']); + } + + /** + * SP SLO Bindings option overrides default bindigs + */ + public function testMetadataHostedSloBindingsOption(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + $config = [ + 'SingleLogoutServiceBinding' => ['urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'], + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertCount(1, $md['SingleLogoutService']); + $this->assertEquals('urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', $md['SingleLogoutService'][0]['Binding']); + } + + /** + * SP empty SLO Bindings option omits SLO in metadata + */ + public function testMetadataHostedSloBindingsEmptyNotInMetadata(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + $config = [ + 'SingleLogoutServiceBinding' => [], + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertCount(0, $md['SingleLogoutService']); + } + + /** + * SP SLO Bindings option with unknown value is accepted as-is + */ + public function testMetadataHostedSloBindingsUnknownValueIsAccepted(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + $config = [ + 'SingleLogoutServiceBinding' => ['urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', 'urn:this:doesnotexist'], + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertCount(2, $md['SingleLogoutService']); + $this->assertEquals('urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', $md['SingleLogoutService'][0]['Binding']); + $this->assertEquals('urn:this:doesnotexist', $md['SingleLogoutService'][1]['Binding']); + } + + /** + * SP SLO Location option is used as URL for all SLO Bindings + */ + public function testMetadataHostedSloURLIsUsedForAllSLOBindings(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + $config = [ + 'SingleLogoutServiceBinding' => ['urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', 'urn:this:doesnotexist'], + 'SingleLogoutServiceLocation' => 'https://sp.example.org/logout', + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertCount(2, $md['SingleLogoutService']); + $this->assertEquals('urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', $md['SingleLogoutService'][0]['Binding']); + $this->assertEquals('urn:this:doesnotexist', $md['SingleLogoutService'][1]['Binding']); + $this->assertEquals('https://sp.example.org/logout', $md['SingleLogoutService'][0]['Location']); + $this->assertEquals('https://sp.example.org/logout', $md['SingleLogoutService'][1]['Location']); + } + + /** + * SP AssertionConsumerService option overrides default bindigs + */ + public function testMetadataHostedAssertionConsumerServiceOption(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + $config = [ + 'AssertionConsumerService' => [ + [ + 'index' => 1, + 'isDefault' => true, + 'Location' => 'https://sp.example.org/ACS', + 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + ], + [ + 'index' => 17, + 'Location' => 'https://sp.example.org/ACSeventeen', + 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact', + ], + ], + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertCount(2, $md['AssertionConsumerService']); + $this->assertEquals('urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', $md['AssertionConsumerService'][0]['Binding']); + $this->assertEquals('https://sp.example.org/ACS', $md['AssertionConsumerService'][0]['Location']); + $this->assertEquals(1, $md['AssertionConsumerService'][0]['index']); + $this->assertTrue($md['AssertionConsumerService'][0]['isDefault']); + + $this->assertEquals('urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact', $md['AssertionConsumerService'][1]['Binding']); + $this->assertEquals('https://sp.example.org/ACSeventeen', $md['AssertionConsumerService'][1]['Location']); + $this->assertEquals(17, $md['AssertionConsumerService'][1]['index']); + $this->assertArrayNotHasKey('isDefault', $md['AssertionConsumerService'][1]); + } + + + /** + * SP config options WantAssertionsSigned, redirect.sign is reflected in metadata + */ + public function testMetadataHostedSigning(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + + $config = [ + 'WantAssertionsSigned' => true, + 'redirect.sign' => true, + 'sign.authnrequest' => true, + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertArrayHasKey('saml20.sign.assertion', $md); + $this->assertArrayHasKey('redirect.validate', $md); + $this->assertTrue($md['saml20.sign.assertion']); + $this->assertTrue($md['redirect.validate']); + $this->assertArrayNotHasKey('validate.authnrequest', $md); + + $config = [ + 'WantAssertionsSigned' => false, + 'redirect.sign' => false, + 'sign.authnrequest' => false, + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertArrayHasKey('saml20.sign.assertion', $md); + $this->assertArrayHasKey('redirect.validate', $md); + $this->assertFalse($md['saml20.sign.assertion']); + $this->assertFalse($md['redirect.validate']); + $this->assertArrayNotHasKey('validate.authnrequest', $md); + + $config = [ + 'sign.authnrequest' => true, + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertArrayNotHasKey('redirect.validate', $md); + $this->assertArrayHasKey('validate.authnrequest', $md); + $this->assertTrue($md['validate.authnrequest']); + } + + /** + * SP config option RegistationInfo is reflected in metadata + */ + public function testMetadataHostedContainsRegistrationInfo(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + + $config = [ + 'RegistrationInfo' => [ + 'authority' => 'urn:mace:sp.example.org', + 'instant' => '2008-01-17T11:28:03.577Z', + 'policies' => ['en' => 'http://sp.example.org/policy', 'es' => 'http://sp.example.org/politica'], + ], + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertArrayHasKey('RegistrationInfo', $md); + $reginfo = $md['RegistrationInfo']; + $this->assertIsArray($reginfo); + $this->assertEquals('urn:mace:sp.example.org', $reginfo['authority']); + $this->assertEquals('2008-01-17T11:28:03.577Z', $reginfo['instant']); + $this->assertIsArray($reginfo['policies']); + $this->assertCount(2, $reginfo['policies']); + $this->assertEquals('http://sp.example.org/politica', $reginfo['policies']['es']); + } + + /** + * SP config option NameIDPolicy is reflected in metadata + */ + public function testMetadataHostedNameIDPolicy(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + + $config = [ + 'NameIDPolicy' => [ 'Format' => 'urn:mace:shibboleth:1.0:nameIdentifier', 'AllowCreate' => true ], + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertArrayHasKey('NameIDFormat', $md); + $this->assertEquals('urn:mace:shibboleth:1.0:nameIdentifier', $md['NameIDFormat']); + } + + /** + * SP config option NameIDPolicy specified in legacy string form is reflected in metadata + */ + public function testMetadataHostedNameIDPolicyString(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + + $config = [ + 'NameIDPolicy' => 'urn:mace:shibboleth:1.0:nameIdentifier', + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertArrayHasKey('NameIDFormat', $md); + $this->assertEquals('urn:mace:shibboleth:1.0:nameIdentifier', $md['NameIDFormat']); + } + + /** + * SP config option NameIDPolicy specified in deprecated form without Format is reflected in metadata + */ + public function testMetadataHostedNameIDPolicyNullFormat(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + + $config = [ + 'NameIDPolicy' => ['AllowCreate' => true], + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertArrayHasKey('NameIDFormat', $md); + $this->assertEquals('urn:oasis:names:tc:SAML:2.0:nameid-format:transient', $md['NameIDFormat']); + } + + /** + * SP config option Organization* are reflected in metadata + */ + public function testMetadataHostedOrganizationData(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + + $config = [ + 'OrganizationName' => [ + 'en' => 'Voorbeeld Organisatie Foundation b.a.', + 'nl' => 'Stichting Voorbeeld Organisatie b.a.', + ], + 'OrganizationDisplayName' => [ + 'en' => 'Example organization', + 'nl' => 'Voorbeeldorganisatie', + ], + 'OrganizationURL' => [ + 'en' => 'https://example.com', + 'nl' => 'https://example.com/nl', + ], + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertEquals('Voorbeeld Organisatie Foundation b.a.', $md['OrganizationName']['en']); + $this->assertEquals('Voorbeeldorganisatie', $md['OrganizationDisplayName']['nl']); + $this->assertEquals('https://example.com/nl', $md['OrganizationURL']['nl']); + } + + /** + * SP config option Organization* without explicit DisplayName are reflected in metadata + */ + public function testMetadataHostedOrganizationDataDefaultForDisplayNameIsName(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + + $config = [ + 'OrganizationName' => [ + 'nl' => 'Stichting Voorbeeld Organisatie b.a.', + ], + 'OrganizationURL' => [ + 'nl' => 'https://example.com/nl', + ], + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertEquals('Stichting Voorbeeld Organisatie b.a.', $md['OrganizationName']['nl']); + $this->assertEquals('Stichting Voorbeeld Organisatie b.a.', $md['OrganizationDisplayName']['nl']); + $this->assertEquals('https://example.com/nl', $md['OrganizationURL']['nl']); + } + + /** + * SP config option Organization* without URL is rejected with an Exception + */ + public function testMetadataHostedOrganizationURLMissingRaisesException(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + + $config = [ + 'OrganizationName' => [ + 'nl' => 'Stichting Voorbeeld Organisatie b.a.', + ], + 'OrganizationDisplayName' => [ + 'nl' => 'Voorbeeldorganisatie', + ], + ]; + $as = new SpTester($info, $config); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('If OrganizationName is set, OrganizationURL must also be set.'); + $md = $as->getHostedMetadata(); + } /** + * SP config option for UIInfo is reflected in metadata + */ + public function testMetadataHostedUIInfo(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + + $config = [ + 'UIInfo' => [ + 'DisplayName' => [ + 'en' => 'English name', + 'es' => 'Nombre en Español' + ], + 'Description' => [ + 'en' => 'English description', + 'es' => 'Descripción en Español' + ], + ], + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertArrayHasKey('UIInfo', $md); + $this->assertIsArray($md['UIInfo']); + $this->assertEquals('Descripción en Español', $md['UIInfo']['Description']['es']); + } + + /** + * SP config option for entity attribute extensions is reflected in metadata + */ + public function testMetadataHostedEntityExtensions(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + + $ea = ['{urn:simplesamlphp:v1}foo' => ['bar']]; + $config = [ + 'EntityAttributes' => $ea, + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertArrayHasKey('EntityAttributes', $md); + $this->assertEquals($ea, $md['EntityAttributes']); + } + + /** + * SP config option for Name, Description, Attributes is in metadata + */ + public function testMetadataHostedNameDescriptionAttributes(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + + $config = [ + 'name' => [ + 'en' => 'My First SP', + ], + 'description' => [ + 'en' => 'This SP is my first one', + ], + 'attributes' => [ + 'mail' => 'urn:oid:0.9.2342.19200300.100.1.3', + 'schacHomeOrganization' => 'urn:oid:1.3.6.1.4.1.25178.1.2.9', + ], + 'attributes.required' => [ + 'eduPersonPrincipalName' => 'urn:oid:1.3.6.1.4.1.5923.1.1.1.6', + ], + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertArrayHasKey('name', $md); + $this->assertEquals('My First SP', $md['name']['en']); + $this->assertArrayHasKey('description', $md); + $this->assertEquals('This SP is my first one', $md['description']['en']); + $this->assertArrayHasKey('attributes', $md); + $this->assertEquals([ + 'mail' => 'urn:oid:0.9.2342.19200300.100.1.3', + 'schacHomeOrganization' => 'urn:oid:1.3.6.1.4.1.25178.1.2.9', + ], $md['attributes']); + $this->assertArrayHasKey('attributes.required', $md); + $this->assertEquals([ + 'eduPersonPrincipalName' => 'urn:oid:1.3.6.1.4.1.5923.1.1.1.6', + ], $md['attributes.required']); + } + + /** + * SP config option for Name, Description require attributes to be specified + */ + public function testMetadataHostedNameDescriptionAbsentWhenNoAttributes(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + + $config = [ + 'name' => [ + 'en' => 'My First SP', + ], + 'description' => [ + 'en' => 'This SP is my first one', + ], + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertArrayNotHasKey('name', $md); + $this->assertArrayNotHasKey('description', $md); + } + + /** + * SP config for attributes also requries name in metadata + */ + public function testMetadataHostedAttributesRequiresName(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + + $config = [ + 'attributes' => [ + 'mail' => 'urn:oid:0.9.2342.19200300.100.1.3', + 'schacHomeOrganization' => 'urn:oid:1.3.6.1.4.1.25178.1.2.9', + ], + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertArrayNotHasKey('attributes', $md); + } + + /** + * SP config for attributes with extra options + */ + public function testMetadataHostedAttributesExtraOptions(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + + $config = [ + 'name' => [ + 'en' => 'My First SP', + ], + 'attributes' => [ + 'mail' => 'urn:oid:0.9.2342.19200300.100.1.3', + 'schacHomeOrganization' => 'urn:oid:1.3.6.1.4.1.25178.1.2.9', + ], + 'attributes.NameFormat' => 'urn:oasis:names:tc:SAML:2.0:attrname-format:uri', + 'attributes.index' => 5, + 'attributes.isDefault' => true, + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertEquals('urn:oasis:names:tc:SAML:2.0:attrname-format:uri', $md['attributes.NameFormat']); + $this->assertEquals(5, $md['attributes.index']); + $this->assertEquals(true, $md['attributes.isDefault']); + } + + /** + * SP config for holder-of-key profile via ProtocolBinding is reflected in metadata + */ + public function testMetadataHolderOfKeyViaProtocolBindingIsInMetadata(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + + $config = [ + 'ProtocolBinding' => 'urn:oasis:names:tc:SAML:2.0:profiles:holder-of-key:SSO:browser', + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertCount(3, $md['AssertionConsumerService']); + $hok = $md['AssertionConsumerService'][2]; + $this->assertIsArray($hok); + $this->assertEquals('urn:oasis:names:tc:SAML:2.0:profiles:holder-of-key:SSO:browser', $hok['Binding']); + $this->assertEquals('http://localhost/simplesaml/module.php/saml/sp/saml2-acs.php/' . $spId, $hok['Location']); + $this->assertEquals(2, $hok['index']); + $this->assertEquals('urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', $hok['hoksso:ProtocolBinding']); + } + + /** + * SP config with certificate are reflected in metdata + */ + public function testMetadatCertificateIsInMetadata(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + + $config = [ + 'privatekey' => self::CERT_KEY, + 'certificate' => self::CERT_PUBLIC, + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertArrayHasKey('keys', $md); + $this->assertIsArray($md['keys']); + $this->assertCount(1, $md['keys']); + $this->assertEquals('X509Certificate', $md['keys'][0]['type']); + $this->assertStringStartsWith('MIICxDCCAi2gAwIBAgIUCJ8EYI', $md['keys'][0]['X509Certificate']); + $this->assertTrue($md['keys'][0]['encryption']); + $this->assertTrue($md['keys'][0]['signing']); + $this->assertEquals('', $md['keys'][0]['prefix']); + } + + /** + * SP config with certificate in rollocer scenario are reflected in metdata + */ + public function testMetadatCertificateInRolloverIsInMetadata(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + + $config = [ + 'privatekey' => self::CERT_KEY, + 'certificate' => self::CERT_PUBLIC, + 'new_privatekey' => self::CERT_OTHER_KEY, + 'new_certificate' => self::CERT_OTHER_PUBLIC, + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $this->assertArrayHasKey('keys', $md); + $this->assertIsArray($md['keys']); + $this->assertCount(2, $md['keys']); + $this->assertEquals('X509Certificate', $md['keys'][0]['type']); + $this->assertEquals('X509Certificate', $md['keys'][1]['type']); + $this->assertStringStartsWith('MIICeTCCAeICAQMwDQYJKoZIhv', $md['keys'][0]['X509Certificate']); + $this->assertStringStartsWith('MIICxDCCAi2gAwIBAgIUCJ8EYI', $md['keys'][1]['X509Certificate']); + $this->assertTrue($md['keys'][0]['encryption']); + $this->assertTrue($md['keys'][0]['signing']); + $this->assertFalse($md['keys'][1]['encryption']); + $this->assertTrue($md['keys'][1]['signing']); + $this->assertEquals('new_', $md['keys'][0]['prefix']); + $this->assertEquals('', $md['keys'][1]['prefix']); + } + + /** + * We only support SAML 2.0 as a protocol with this auth source + */ + public function testSupportedProtocolsReturnsSAML20Only(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + $config = []; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + $protocols = $as->getSupportedProtocols(); + $this->assertIsArray($protocols); + $this->assertCount(1, $protocols); + $this->assertEquals('urn:oasis:names:tc:SAML:2.0:protocol', $protocols[0]); + } + + /** + * We only support SAML 2.0 as a protocol with this auth source + */ + public function testSAML11BindingsDoesNotInfluenceProtocolsSupported(): void + { + $spId = 'myhosted-sp'; + $info = ['AuthId' => $spId]; + $config = [ + 'AssertionConsumerService' => [ + [ + 'index' => 1, + 'isDefault' => true, + 'Location' => 'https://sp.example.org/ACS', + 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + ], + [ + 'index' => 17, + 'Location' => 'https://sp.example.org/ACS', + 'Binding' => 'urn:oasis:names:tc:SAML:1.0:profiles:browser-post', + ], + ], + ]; + $as = new SpTester($info, $config); + + $md = $as->getHostedMetadata(); + + $protocols = $as->getSupportedProtocols(); + $this->assertIsArray($protocols); + $this->assertCount(1, $protocols); + $this->assertEquals('urn:oasis:names:tc:SAML:2.0:protocol', $protocols[0]); + } + + /** * Test setting a logout-extension */ public function testLogoutExtensions(): void diff --git a/www/saml2/idp/metadata.php b/www/saml2/idp/metadata.php index 2f6807e8ecf6d5d66722c58367c0215e949b0dcd..68404390f2b213293753c5cb7c0d1e3e415aad23 100644 --- a/www/saml2/idp/metadata.php +++ b/www/saml2/idp/metadata.php @@ -173,6 +173,10 @@ try { } } + if ($idpmeta->hasValue('saml:Extensions')) { + $metaArray['saml:Extensions'] = $idpmeta->getArray('saml:Extensions'); + } + if ($idpmeta->hasValue('UIInfo')) { $metaArray['UIInfo'] = $idpmeta->getArray('UIInfo'); }