diff --git a/routing/routes/routes.yml b/routing/routes/routes.yml index 3bea079..1a0e1b4 100644 --- a/routing/routes/routes.yml +++ b/routing/routes/routes.yml @@ -55,3 +55,10 @@ adfs-wstrust-usernamemixed: _controller: 'SimpleSAML\Module\adfs\Controller\Adfs::usernamemixed' } methods: [POST] + +adfs-wstrust-certificatemixed: + path: /ws-trust/2005/services/certificatemixed + defaults: { + _controller: 'SimpleSAML\Module\adfs\Controller\Adfs::certificatemixed' + } + methods: [POST] diff --git a/src/Controller/Adfs.php b/src/Controller/Adfs.php index ae9bc44..75433ca 100644 --- a/src/Controller/Adfs.php +++ b/src/Controller/Adfs.php @@ -240,4 +240,29 @@ public function usernamemixed(Request $request): Response return ADFS_IDP::receivePassiveAuthnRequest($request, $soapEnvelope, $idp); } + + + /** + * @param \Symfony\Component\HttpFoundation\Request $request + * @return \Symfony\Component\HttpFoundation\Response + */ + public function certificatemixed(Request $request): Response + { + if (!$this->config->getOptionalBoolean('enable.adfs-idp', false)) { + throw new SspError\Error('NOACCESS'); + } + + $soapMessage = $request->getContent(); + if ($soapMessage === false) { + throw new SspError\BadRequest('Missing SOAP-content.'); + } + + $domDocument = DOMDocumentFactory::fromString($soapMessage); + $soapEnvelope = Envelope::fromXML($domDocument->documentElement); + + $idpEntityId = $this->metadata->getMetaDataCurrentEntityID('adfs-idp-hosted'); + $idp = PassiveIdP::getById($this->config, 'adfs:' . $idpEntityId); + + return ADFS_IDP::receiveCertificateAuthnRequest($request, $soapEnvelope, $idp); + } } diff --git a/src/IdP/ADFS.php b/src/IdP/ADFS.php index e2c8ff4..50dc4f0 100644 --- a/src/IdP/ADFS.php +++ b/src/IdP/ADFS.php @@ -43,6 +43,7 @@ use SimpleSAML\WSSecurity\XML\wsp\AppliesTo; use SimpleSAML\WSSecurity\XML\wsse\KeyIdentifier; use SimpleSAML\WSSecurity\XML\wsse\Password; +use SimpleSAML\WSSecurity\XML\wsse\BinarySecurityToken; use SimpleSAML\WSSecurity\XML\wsse\Security; use SimpleSAML\WSSecurity\XML\wsse\SecurityTokenReference; use SimpleSAML\WSSecurity\XML\wsse\UsernameToken; @@ -65,6 +66,7 @@ use SimpleSAML\XMLSecurity\Key\PrivateKey; use SimpleSAML\XMLSecurity\Key\X509Certificate as PublicKey; use SimpleSAML\XMLSecurity\XML\ds\KeyInfo; +use SimpleSAML\XMLSecurity\XML\ds\Signature; use SimpleSAML\XMLSecurity\XML\ds\X509Certificate; use SimpleSAML\XMLSecurity\XML\ds\X509Data; use Symfony\Component\HttpFoundation\Request; @@ -169,6 +171,181 @@ function () use ($idp, &$state) { } + /** + * @param \Symfony\Component\HttpFoundation\Request $request + * @param \SimpleSAML\SOAP\XML\env_200305\Envelope $soapEnvelope + * @param \SimpleSAML\Module\adfs\IdP\PassiveIdP $idp + * @return \Symfony\Component\HttpFoundation\StreamedResponse + * @throws \SimpleSAML\Error\MetadataNotFound + */ + public static function receiveCertificateAuthnRequest( + Request $request, + Envelope $soapEnvelope, + PassiveIdP $idp, + ): StreamedResponse { + // Parse the SOAP-header + $header = $soapEnvelope->getHeader(); + + $to = To::getChildrenOfClass($header->toXML()); + Assert::count($to, 1, 'Missing To in SOAP Header.'); + $to = array_pop($to); + + $action = Action::getChildrenOfClass($header->toXML()); + Assert::count($action, 1, 'Missing Action in SOAP Header.'); + $action = array_pop($action); + + $messageid = MessageID::getChildrenOfClass($header->toXML()); + Assert::count($messageid, 1, 'Missing MessageID in SOAP Header.'); + $messageid = array_pop($messageid); + + $security = Security::getChildrenOfClass($header->toXML()); + Assert::count($security, 1, 'Missing Security in SOAP Header.'); + $security = array_pop($security); + + // Parse the SOAP-body + $body = $soapEnvelope->getBody(); + + $requestSecurityToken = RequestSecurityToken::getChildrenOfClass($body->toXML()); + Assert::count($requestSecurityToken, 1, 'Missing RequestSecurityToken in SOAP Body.'); + $requestSecurityToken = array_pop($requestSecurityToken); + + $appliesTo = AppliesTo::getChildrenOfClass($requestSecurityToken->toXML()); + Assert::count($appliesTo, 1, 'Missing AppliesTo in RequestSecurityToken.'); + $appliesTo = array_pop($appliesTo); + + $endpointReference = EndpointReference::getChildrenOfClass($appliesTo->toXML()); + Assert::count($endpointReference, 1, 'Missing EndpointReference in AppliesTo.'); + $endpointReference = array_pop($endpointReference); + + // Make sure the message was addressed to us. + if ($to === null || $request->server->get('SCRIPT_URI') !== $to->getContent()) { + throw new Error\BadRequest('This server is not the audience for the message received.'); + } + + // Ensure we know the issuer + $issuer = $endpointReference->getAddress()->getContent(); + + $metadata = MetaDataStorageHandler::getMetadataHandler(Configuration::getInstance()); + $spMetadata = $metadata->getMetaDataConfig($issuer, 'adfs-sp-remote'); + + // Extract Client Certificate + $bst = BinarySecurityToken::getChildrenOfClass($security->toXML()); + Assert::count($bst, 1, 'Missing BinarySecurityToken in Security.'); + $bst = array_pop($bst); + $clientCertData = $bst->getContent(); + $clientCert = new PublicKey(PublicKey::normalizeCertificate($clientCertData)); + + // Verify XML Signature + $signatures = Signature::getChildrenOfClass($security->toXML()); + Assert::count($signatures, 1, 'Missing Signature in Security header.'); + /** @var \SimpleSAML\XMLSecurity\XML\ds\Signature $signature */ + $signature = array_pop($signatures); + + // Verify the signature against the client certificate + if (!$signature->verify($clientCert)) { + throw new Error\BadRequest('SOAP Signature verification failed.'); + } + + // Verify Certificate Chain against Trusted CAs + $idpConfig = $idp->getConfig(); + $caFiles = $idpConfig->getOptionalArray('certificate_authorities', []); + + if (empty($caFiles)) { + throw new Error\Exception('No certificate authorities configured for ADFS certificatemixed endpoint.'); + } + + // Create a temporary file to hold the client cert for openssl check + $clientCertPem = $clientCert->getPEM(); + + $verified = false; + foreach ($caFiles as $caFile) { + // Resolve path + $configUtils = new Utils\Config(); + $caFilePath = $configUtils->getCertPath($caFile); + + // Verify + $certRes = openssl_x509_read($clientCertPem); + if ($certRes) { + $result = openssl_x509_checkpurpose($certRes, X509_PURPOSE_SSL_CLIENT, [$caFilePath]); + if ($result === true) { + $verified = true; + break; + } + } + } + + if (!$verified) { + throw new Error\BadRequest('Client certificate could not be verified against trusted CAs.'); + } + + // Authenticate User + $certDetails = openssl_x509_parse($clientCertPem); + $subject = $certDetails['subject']; + $subjectDN = ''; // Reconstruct DN or use what's available + // openssl_x509_parse returns subject as array. + // We can just use the name from the parser or try to get the DN string. + + // Simple mapping: Use the CN or emailAddress from Subject + $username = null; + if (isset($subject['emailAddress'])) { + $username = $subject['emailAddress']; + } elseif (isset($subject['CN'])) { + $username = $subject['CN']; + } + + // Also check SANs + if (isset($certDetails['extensions']['subjectAltName'])) { + // Format usually: "email:foo@bar.com, DNS:example.com" + $sans = explode(',', $certDetails['extensions']['subjectAltName']); + foreach ($sans as $san) { + $san = trim($san); + if (str_starts_with($san, 'email:')) { + $username = substr($san, 6); + break; + } + if (str_starts_with($san, 'othername:') && strpos($san, '1.3.6.1.4.1.311.20.2.3') !== false) { + // UPN OID + // Parsing UPN from othername is complex (ASN.1). Skipping for simplicity unless needed. + } + } + } + + if (!$username) { + throw new Error\BadRequest('Could not extract username (CN, Email, or SAN Email) from client certificate.'); + } + + Logger::info('ADFS certificatemixed: Authenticated user ' . $username); + + $attributes = [ + 'http://schemas.xmlsoap.org/claims/UPN' => [$username], + 'http://schemas.microsoft.com/LiveID/Federation/2008/05/ImmutableID' => [$username], // Fallback + // Add other attributes if available or mapped + ]; + + $state = [ + 'Responder' => [ADFS::class, 'sendPassiveResponse'], + 'SPMetadata' => $spMetadata->toArray(), + 'MessageID' => $messageid->getContent(), + 'saml:Binding' => SAML2_C::BINDING_PAOS, + 'Attributes' => $attributes, + 'IdPMetadata' => $idpConfig->toArray(), + 'saml:NameID' => [SAML2_C::NAMEID_UNSPECIFIED => new \SimpleSAML\SAML2\XML\saml\NameID($username)], + 'adfs:wctx' => null, // Not usually present in this flow or extracted differently? + // In receivePassiveAuthnRequest (usernamemixed), wctx is not extracted from query, + // but the SOAP request might contain context? + // Standard usernamemixed flow doesn't use wctx in the SOAP body usually. + 'adfs:wreply' => $spMetadata->getValue('prp'), + ]; + + // Call sendPassiveResponse directly + return new StreamedResponse( + function () use ($state) { + ADFS::sendPassiveResponse($state); + }, + ); + } + + /** * @param \Symfony\Component\HttpFoundation\Request $request * @param \SimpleSAML\IdP $idp diff --git a/src/IdP/MetadataBuilder.php b/src/IdP/MetadataBuilder.php index 4d40916..01027b1 100644 --- a/src/IdP/MetadataBuilder.php +++ b/src/IdP/MetadataBuilder.php @@ -196,15 +196,38 @@ public function getSecurityTokenService(): SecurityTokenServiceType { $defaultEndpoint = Module::getModuleURL('adfs') . '/idp/prp.php'; + $stsEndpoints = [ + new SecurityTokenServiceEndpoint([ + new EndpointReference(new Address($defaultEndpoint)), + ]), + ]; + + if ($this->config->getOptionalBoolean('adfs.mex_in_metadata', true)) { + $mexEndpoint = Module::getModuleURL('adfs') . '/ws-trust/mex'; + $stsEndpoints[] = new SecurityTokenServiceEndpoint([ + new EndpointReference(new Address($mexEndpoint)), + ]); + } + + if ($this->config->getOptionalBoolean('adfs.certificatemixed_in_metadata', false)) { + $certEndpoint = Module::getModuleURL('adfs') . '/ws-trust/2005/services/certificatemixed'; + $stsEndpoints[] = new SecurityTokenServiceEndpoint([ + new EndpointReference(new Address($certEndpoint)), + ]); + } + + if ($this->config->getOptionalBoolean('adfs.usernamemixed_in_metadata', false)) { + $unEndpoint = Module::getModuleURL('adfs') . '/ws-trust/2005/services/usernamemixed'; + $stsEndpoints[] = new SecurityTokenServiceEndpoint([ + new EndpointReference(new Address($unEndpoint)), + ]); + } + return new SecurityTokenServiceType( protocolSupportEnumeration: [C::NS_TRUST_200512, C::NS_TRUST_200502, C::NS_FED], keyDescriptors: $this->getKeyDescriptor(), tokenTypesOffered: new TokenTypesOffered([new TokenType('urn:oasis:names:tc:SAML:1.0:assertion')]), - securityTokenServiceEndpoint: [ - new SecurityTokenServiceEndpoint([ - new EndpointReference(new Address($defaultEndpoint)), - ]), - ], + securityTokenServiceEndpoint: $stsEndpoints, passiveRequestorEndpoint: [ new PassiveRequestorEndpoint([ new EndpointReference(new Address($defaultEndpoint)),