Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions routing/routes/routes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
25 changes: 25 additions & 0 deletions src/Controller/Adfs.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
177 changes: 177 additions & 0 deletions src/IdP/ADFS.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
33 changes: 28 additions & 5 deletions src/IdP/MetadataBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down