- Refactored `SamlIdpService` to enhance SAML NameID handling, adding support for configurable formats and sources with `SamlNameIdFormatEnum`. - Enabled custom attribute mapping with dynamic claims resolution, supporting app-level configurations. - Standardized SAML namespace usage across all element creation methods to ensure compliance with SAML 2.0 specifications. - Improved cryptographic signature handling with Exclusive Canonicalization (C14N) and proper DOM node positioning. - Added new unit tests to verify SAML responses and cryptographic signature validation.
574 lines
22 KiB
PHP
574 lines
22 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Enums\SamlNameIdFormatEnum;
|
|
use App\Models\{ConnectedApp, User};
|
|
use DOMDocument;
|
|
use DOMElement;
|
|
use DOMException;
|
|
use Exception;
|
|
use Illuminate\Support\Facades\{File, Log};
|
|
use Illuminate\Support\Str;
|
|
use RobRichards\XMLSecLibs\{XMLSecurityDSig, XMLSecurityKey};
|
|
use RuntimeException;
|
|
|
|
class SamlIdpService
|
|
{
|
|
/**
|
|
* Decode and parse an incoming SAMLRequest (GET/POST).
|
|
*
|
|
* @return array{requestId: string, issuer: string, acsUrl: string}
|
|
*
|
|
* @throws Exception
|
|
*/
|
|
public function parseRequest(string $samlRequest): array
|
|
{
|
|
Log::debug('SamlIdpService: Parsing incoming SAMLRequest.', [
|
|
'payload_length' => mb_strlen($samlRequest),
|
|
]);
|
|
|
|
$decoded = base64_decode($samlRequest, true);
|
|
if (false === $decoded) {
|
|
Log::error('SamlIdpService: Failed to base64 decode SAMLRequest.');
|
|
throw new Exception('Failed to base64 decode SAMLRequest.');
|
|
}
|
|
|
|
// Try deflated (GET Redirect binding), fallback to raw XML (POST binding)
|
|
$inflated = @gzinflate($decoded);
|
|
if (false !== $inflated) {
|
|
Log::debug('SamlIdpService: SAMLRequest successfully inflated using gzip/deflate.');
|
|
$xml = $inflated;
|
|
} else {
|
|
Log::debug('SamlIdpService: SAMLRequest gzip inflation failed/skipped; falling back to raw XML.');
|
|
$xml = $decoded;
|
|
}
|
|
|
|
$dom = new DOMDocument();
|
|
$dom->preserveWhiteSpace = false;
|
|
|
|
// Securely handle XML loading without using '@' suppression
|
|
libxml_use_internal_errors(true);
|
|
$loaded = $dom->loadXML($xml);
|
|
$xmlErrors = libxml_get_errors();
|
|
libxml_clear_errors();
|
|
|
|
if (! $loaded) {
|
|
$formattedErrors = array_map(fn ($err) => mb_trim($err->message), $xmlErrors);
|
|
Log::error('SamlIdpService: Failed to load SAMLRequest XML.', [
|
|
'errors' => $formattedErrors,
|
|
'xml_snippet' => mb_substr($xml, 0, 500),
|
|
]);
|
|
throw new Exception('Failed to load SAMLRequest XML.');
|
|
}
|
|
|
|
$issuerNode = $dom->getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Issuer')->item(0)
|
|
?? $dom->getElementsByTagName('Issuer')->item(0);
|
|
|
|
$issuer = $issuerNode ? mb_trim($issuerNode->nodeValue) : '';
|
|
$requestId = $dom->documentElement->getAttribute('ID');
|
|
$acsUrl = $dom->documentElement->getAttribute('AssertionConsumerServiceURL');
|
|
|
|
Log::info('SamlIdpService: SAMLRequest XML parsed successfully.', [
|
|
'requestId' => $requestId,
|
|
'issuer' => $issuer,
|
|
'acsUrl' => $acsUrl,
|
|
]);
|
|
|
|
return [
|
|
'requestId' => $requestId,
|
|
'issuer' => $issuer,
|
|
'acsUrl' => $acsUrl,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Verify if the SAML SP Issuer is registered as a connected app and active.
|
|
*/
|
|
public function findConnectedApp(string $issuer): ?ConnectedApp
|
|
{
|
|
Log::debug('SamlIdpService: Querying ConnectedApp database for entity ID.', ['issuer' => $issuer]);
|
|
$app = ConnectedApp::query()
|
|
->where('settings->saml->entity_id', $issuer)
|
|
->first();
|
|
|
|
if ($app) {
|
|
Log::info('SamlIdpService: ConnectedApp found for issuer.', [
|
|
'issuer' => $issuer,
|
|
'app_id' => $app->id,
|
|
'app_name' => $app->name,
|
|
'status' => $app->status->name ?? 'unknown',
|
|
]);
|
|
} else {
|
|
Log::warning('SamlIdpService: No ConnectedApp found matching issuer entity ID.', ['issuer' => $issuer]);
|
|
}
|
|
|
|
return $app;
|
|
}
|
|
|
|
/**
|
|
* Generate the XML SAML metadata for this Identity Provider.
|
|
*/
|
|
public function generateMetadata(string $idpEntityId, string $ssoUrl): string
|
|
{
|
|
Log::info('SamlIdpService: Generating SAML IdP metadata XML.', [
|
|
'idpEntityId' => $idpEntityId,
|
|
'ssoUrl' => $ssoUrl,
|
|
]);
|
|
$certPem = $this->getPublicCertificate();
|
|
$certClean = $this->cleanPem($certPem);
|
|
|
|
$metadata = mb_trim(<<<XML
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<EntityDescriptor entityID="{$idpEntityId}" xmlns="urn:oasis:names:tc:SAML:2.0:metadata">
|
|
<IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
|
<KeyDescriptor use="signing">
|
|
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
|
<ds:X509Data>
|
|
<ds:X509Certificate>{$certClean}</ds:X509Certificate>
|
|
</ds:X509Data>
|
|
</ds:KeyInfo>
|
|
</KeyDescriptor>
|
|
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{$ssoUrl}"/>
|
|
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{$ssoUrl}"/>
|
|
</IDPSSODescriptor>
|
|
</EntityDescriptor>
|
|
XML);
|
|
|
|
Log::info('SamlIdpService: SAML IdP metadata XML successfully generated.', [
|
|
'length' => mb_strlen($metadata),
|
|
]);
|
|
|
|
return $metadata;
|
|
}
|
|
|
|
/**
|
|
* Load IdP Public Certificate.
|
|
*/
|
|
private function getPublicCertificate(): string
|
|
{
|
|
$path = storage_path('app/saml/idp.crt');
|
|
Log::debug('SamlIdpService: Loading public certificate.', ['path' => $path]);
|
|
try {
|
|
$cert = File::get($path);
|
|
Log::debug('SamlIdpService: Public certificate loaded successfully.', [
|
|
'path' => $path,
|
|
'length' => mb_strlen($cert),
|
|
]);
|
|
|
|
return $cert;
|
|
} catch (Exception $e) {
|
|
Log::error("SamlIdpService: SAML public certificate not found at [$path].", [
|
|
'path' => $path,
|
|
'message' => $e->getMessage(),
|
|
]);
|
|
throw new RuntimeException("SAML public certificate not found at [$path].", code: 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper to clean up PEM certificate for metadata insertion.
|
|
*/
|
|
private function cleanPem(string $pem): string
|
|
{
|
|
return str_replace(['-----BEGIN CERTIFICATE-----', '-----END CERTIFICATE-----', "\r", "\n", ' '], '', $pem);
|
|
}
|
|
|
|
/**
|
|
* Generate a signed SAMLResponse XML for a successful authentication.
|
|
*
|
|
* @throws Exception
|
|
*/
|
|
public function generateResponse(User $user, ConnectedApp $app, string $requestId, string $acsUrl): string
|
|
{
|
|
$idpEntityId = route('saml.metadata');
|
|
/**
|
|
* @var array{
|
|
* saml?: array{
|
|
* entity_id?: string,
|
|
* acs_url?: string
|
|
* }
|
|
* } $settings
|
|
*/
|
|
$settings = (array) $app->settings;
|
|
$spEntityId = $settings['saml']['entity_id'] ?? $acsUrl;
|
|
|
|
$responseId = '_'.Str::uuid()->toString();
|
|
$assertionId = '_'.Str::uuid()->toString();
|
|
|
|
Log::info('SamlIdpService: Initiating SAML Response generation.', [
|
|
'user_id' => $user->id,
|
|
'user_email' => $user->email,
|
|
'app_id' => $app->id,
|
|
'app_name' => $app->name,
|
|
'sp_entity_id' => $spEntityId,
|
|
'request_id' => $requestId,
|
|
'acs_url' => $acsUrl,
|
|
'response_id' => $responseId,
|
|
'assertion_id' => $assertionId,
|
|
]);
|
|
|
|
// Prepare Timestamps
|
|
$now = time();
|
|
$timestamps = [
|
|
'issue' => gmdate("Y-m-d\TH:i:s\Z", $now),
|
|
'notBefore' => gmdate("Y-m-d\TH:i:s\Z", $now - 30),
|
|
'expire' => gmdate("Y-m-d\TH:i:s\Z", $now + 300),
|
|
];
|
|
|
|
Log::debug('SamlIdpService: Computed SAML assertion timestamps.', $timestamps);
|
|
|
|
$dom = new DOMDocument('1.0', 'UTF-8');
|
|
$dom->preserveWhiteSpace = false;
|
|
|
|
// 1. Create Base Response
|
|
Log::debug('SamlIdpService: Constructing samlp:Response element.');
|
|
$response = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:protocol', 'samlp:Response');
|
|
$response->setAttribute('ID', $responseId);
|
|
$response->setAttribute('InResponseTo', $requestId);
|
|
$response->setAttribute('Version', '2.0');
|
|
$response->setAttribute('IssueInstant', $timestamps['issue']);
|
|
$response->setAttribute('Destination', $acsUrl);
|
|
|
|
$response->appendChild($dom->createElementNS(
|
|
'urn:oasis:names:tc:SAML:2.0:assertion',
|
|
'saml:Issuer',
|
|
$idpEntityId
|
|
));
|
|
|
|
$status = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:protocol', 'samlp:Status');
|
|
$statusCode = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:protocol', 'samlp:StatusCode');
|
|
$statusCode->setAttribute('Value', 'urn:oasis:names:tc:SAML:2.0:status:Success');
|
|
$status->appendChild($statusCode);
|
|
$response->appendChild($status);
|
|
|
|
// 2. Create Assertion
|
|
Log::debug('SamlIdpService: Constructing saml:Assertion element.');
|
|
$assertion = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:assertion', 'saml:Assertion');
|
|
$assertion->setAttribute('ID', $assertionId);
|
|
$assertion->setAttribute('Version', '2.0');
|
|
$assertion->setAttribute('IssueInstant', $timestamps['issue']);
|
|
$assertion->appendChild($dom->createElementNS(
|
|
'urn:oasis:names:tc:SAML:2.0:assertion',
|
|
'saml:Issuer',
|
|
$idpEntityId
|
|
));
|
|
|
|
// 3. Build Assertion Sub-components
|
|
Log::debug('SamlIdpService: Appending assertion components (Subject, Conditions, AuthnStatement, Attributes).');
|
|
$this->buildSubject($dom, $assertion, $user, $requestId, $acsUrl, $timestamps['expire'], $app);
|
|
$this->buildConditions($dom, $assertion, $spEntityId, $timestamps['notBefore'], $timestamps['expire']);
|
|
$this->buildAuthnStatement($dom, $assertion, $timestamps['issue'], $assertionId);
|
|
$this->buildAttributeStatement($dom, $assertion, $user, $app);
|
|
|
|
$response->appendChild($assertion);
|
|
$dom->appendChild($response);
|
|
|
|
// 4. Sign Assertion & Return Base64 payload
|
|
Log::info('SamlIdpService: Invoking cryptographic signing for the Assertion node.');
|
|
$this->signAssertion($dom, $assertion);
|
|
|
|
$xml = $dom->saveXML();
|
|
Log::debug('SamlIdpService: SAML Response XML generated successfully.', [
|
|
'xml_length' => mb_strlen($xml),
|
|
]);
|
|
|
|
$base64Response = base64_encode($xml);
|
|
Log::info('SamlIdpService: SAML Response base64 encoded successfully.', [
|
|
'base64_length' => mb_strlen($base64Response),
|
|
]);
|
|
|
|
return $base64Response;
|
|
}
|
|
|
|
/**
|
|
* Build the SAML Subject clause.
|
|
*
|
|
* @throws DOMException
|
|
*/
|
|
private function buildSubject(
|
|
DOMDocument $dom,
|
|
DOMElement $assertion,
|
|
User $user,
|
|
string $requestId,
|
|
string $acsUrl,
|
|
string $expireInstant,
|
|
ConnectedApp $app
|
|
): void {
|
|
/** @var array<string, mixed> $settings */
|
|
$settings = (array) $app->settings;
|
|
$nameIdSource = $settings['saml']['nameid_source'] ?? 'immutable_id';
|
|
|
|
$formatKey = mb_strtolower($settings['saml']['nameid_format'] ?? 'persistent');
|
|
$nameIdFormat = match ($formatKey) {
|
|
'email', 'emailaddress' => SamlNameIdFormatEnum::Email,
|
|
'transient' => SamlNameIdFormatEnum::Transient,
|
|
'unspecified' => SamlNameIdFormatEnum::Unspecified,
|
|
default => SamlNameIdFormatEnum::Persistent,
|
|
};
|
|
|
|
$formatUrn = $nameIdFormat->value;
|
|
|
|
$nameIdValue = match ($nameIdSource) {
|
|
'email' => $user->email,
|
|
'id' => (string) $user->id,
|
|
default => $user->immutable_id ?? base64_encode((string) $user->id),
|
|
};
|
|
|
|
Log::debug('SamlIdpService: Constructing saml:Subject component.', [
|
|
'user_id' => $user->id,
|
|
'nameid_format' => $formatUrn,
|
|
'nameid_source' => $nameIdSource,
|
|
'resolved_value' => $nameIdValue,
|
|
]);
|
|
|
|
$subject = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:assertion', 'saml:Subject');
|
|
$nameId = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:assertion', 'saml:NameID', $nameIdValue);
|
|
$nameId->setAttribute('Format', $formatUrn);
|
|
$subject->appendChild($nameId);
|
|
|
|
$subjectConfirmation = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:assertion', 'saml:SubjectConfirmation');
|
|
$subjectConfirmation->setAttribute('Method', 'urn:oasis:names:tc:SAML:2.0:cm:bearer');
|
|
|
|
$confirmationData = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:assertion', 'saml:SubjectConfirmationData');
|
|
$confirmationData->setAttribute('InResponseTo', $requestId);
|
|
$confirmationData->setAttribute('NotOnOrAfter', $expireInstant);
|
|
$confirmationData->setAttribute('Recipient', $acsUrl);
|
|
|
|
$subjectConfirmation->appendChild($confirmationData);
|
|
$subject->appendChild($subjectConfirmation);
|
|
$assertion->appendChild($subject);
|
|
}
|
|
|
|
/**
|
|
* Build the SAML Conditions clause.
|
|
*
|
|
* @throws DOMException
|
|
*/
|
|
private function buildConditions(
|
|
DOMDocument $dom,
|
|
DOMElement $assertion,
|
|
string $spEntityId,
|
|
string $notBefore,
|
|
string $expireInstant
|
|
): void {
|
|
Log::debug('SamlIdpService: Constructing saml:Conditions component.', [
|
|
'sp_entity_id' => $spEntityId,
|
|
'not_before' => $notBefore,
|
|
'not_on_or_after' => $expireInstant,
|
|
]);
|
|
|
|
$conditions = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:assertion', 'saml:Conditions');
|
|
$conditions->setAttribute('NotBefore', $notBefore);
|
|
$conditions->setAttribute('NotOnOrAfter', $expireInstant);
|
|
|
|
$audienceRestriction = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:assertion', 'saml:AudienceRestriction');
|
|
$audienceRestriction->appendChild($dom->createElementNS('urn:oasis:names:tc:SAML:2.0:assertion', 'saml:Audience', $spEntityId));
|
|
|
|
$conditions->appendChild($audienceRestriction);
|
|
$assertion->appendChild($conditions);
|
|
}
|
|
|
|
/**
|
|
* Build the SAML Authentication Statement clause.
|
|
*
|
|
* @throws DOMException
|
|
*/
|
|
private function buildAuthnStatement(
|
|
DOMDocument $dom,
|
|
DOMElement $assertion,
|
|
string $issueInstant,
|
|
string $assertionId
|
|
): void {
|
|
Log::debug('SamlIdpService: Constructing saml:AuthnStatement component.', [
|
|
'authn_instant' => $issueInstant,
|
|
'session_index' => $assertionId,
|
|
'authn_class' => 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport',
|
|
]);
|
|
|
|
$authnStatement = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:assertion', 'saml:AuthnStatement');
|
|
$authnStatement->setAttribute('AuthnInstant', $issueInstant);
|
|
$authnStatement->setAttribute('SessionIndex', $assertionId);
|
|
|
|
$authnContext = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:assertion', 'saml:AuthnContext');
|
|
$authnContext->appendChild(
|
|
$dom->createElementNS(
|
|
'urn:oasis:names:tc:SAML:2.0:assertion',
|
|
'saml:AuthnContextClassRef',
|
|
'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'
|
|
)
|
|
);
|
|
|
|
$authnStatement->appendChild($authnContext);
|
|
$assertion->appendChild($authnStatement);
|
|
}
|
|
|
|
/**
|
|
* Build the SAML Attribute Statement (Claims mapping).
|
|
*/
|
|
private function buildAttributeStatement(DOMDocument $dom, DOMElement $assertion, User $user, ConnectedApp $app): void
|
|
{
|
|
$statement = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:assertion', 'saml:AttributeStatement');
|
|
|
|
/** @var array<string, mixed> $settings */
|
|
$settings = (array) $app->settings;
|
|
$customAttributes = $settings['saml']['attributes'] ?? [];
|
|
|
|
if (empty($customAttributes)) {
|
|
$claims = [
|
|
'IDPEmail' => $user->email,
|
|
'mail' => $user->email,
|
|
'displayName' => $user->name,
|
|
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn' => $user->email,
|
|
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' => $user->email,
|
|
];
|
|
} else {
|
|
$claims = [];
|
|
foreach ($customAttributes as $samlName => $userField) {
|
|
$claims[$samlName] = match ($userField) {
|
|
'email' => $user->email,
|
|
'name' => $user->name,
|
|
'id' => (string) $user->id,
|
|
'immutable_id' => $user->immutable_id ?? base64_encode((string) $user->id),
|
|
default => null,
|
|
};
|
|
}
|
|
}
|
|
|
|
Log::debug('SamlIdpService: Constructing saml:AttributeStatement (claims mapping).', [
|
|
'user_id' => $user->id,
|
|
'claims_count' => count($claims),
|
|
'claims' => array_keys($claims),
|
|
]);
|
|
|
|
foreach ($claims as $name => $value) {
|
|
if (null !== $value) {
|
|
$this->addAttribute($dom, $statement, $name, (string) $value);
|
|
}
|
|
}
|
|
|
|
$assertion->appendChild($statement);
|
|
}
|
|
|
|
/**
|
|
* Add a simple attribute to SAML AttributeStatement.
|
|
*
|
|
* @throws DOMException
|
|
*/
|
|
private function addAttribute(DOMDocument $dom, DOMElement $statement, string $name, string $value): void
|
|
{
|
|
Log::debug('SamlIdpService: Mapping claim attribute.', [
|
|
'name' => $name,
|
|
'value' => $value,
|
|
]);
|
|
|
|
$attribute = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:assertion', 'saml:Attribute');
|
|
$attribute->setAttribute('Name', $name);
|
|
$attribute->setAttribute('NameFormat', 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic');
|
|
|
|
$val = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:assertion', 'saml:AttributeValue', htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8'));
|
|
$attribute->appendChild($val);
|
|
$statement->appendChild($attribute);
|
|
}
|
|
|
|
/**
|
|
* Cryptographically sign the Assertion node in DOMDocument.
|
|
*/
|
|
/**
|
|
* Cryptographically sign the Assertion node in DOMDocument.
|
|
*/
|
|
private function signAssertion(DOMDocument $dom, DOMElement|DOMDocument $assertion): void
|
|
{
|
|
Log::info('SamlIdpService: Starting cryptographic assertion signing.', [
|
|
'assertion_id' => $assertion->getAttribute('ID'),
|
|
]);
|
|
|
|
try {
|
|
$privateKeyPem = $this->getPrivateKey();
|
|
$certPem = $this->getPublicCertificate();
|
|
|
|
Log::debug('SamlIdpService: Instantiating XMLSecurityDSig for Assertion.');
|
|
$objDSig = new XMLSecurityDSig();
|
|
|
|
// NATIVE FIX 1: Enforce Exclusive C14N everywhere.
|
|
// Inclusive C14N inherits parent namespaces and breaks the hash upon document import.
|
|
$objDSig->setCanonicalMethod(XMLSecurityDSig::EXC_C14N);
|
|
$assertion->setIdAttribute('ID', true);
|
|
|
|
Log::debug('SamlIdpService: Adding reference to XMLSignature.', [
|
|
'algorithm' => 'SHA256',
|
|
]);
|
|
$objDSig->addReference(
|
|
$assertion,
|
|
XMLSecurityDSig::SHA256,
|
|
[
|
|
'http://www.w3.org/2000/09/xmldsig#enveloped-signature',
|
|
'http://www.w3.org/2001/10/xml-exc-c14n#',
|
|
],
|
|
['id_name' => 'ID', 'overwrite' => false]
|
|
);
|
|
|
|
Log::debug('SamlIdpService: Loading RSA-SHA256 private key.');
|
|
$objKey = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, ['type' => 'private']);
|
|
$objKey->loadKey($privateKeyPem);
|
|
|
|
Log::debug('SamlIdpService: Appending and computing signature.');
|
|
// NATIVE FIX 2: Pass $assertion as the second argument.
|
|
// This physically attaches the empty signature node to the DOM *first*,
|
|
// ensuring it inherits the exact final namespace context before hashing.
|
|
$objDSig->sign($objKey, $assertion);
|
|
|
|
Log::debug('SamlIdpService: Appending X509 certificate to signature.');
|
|
$objDSig->add509Cert($certPem);
|
|
|
|
Log::debug('SamlIdpService: Moving signature to valid XSD position.');
|
|
// NATIVE FIX 3: Reorder the DOM.
|
|
// Because this is an Enveloped signature, shifting the node inside the same parent
|
|
// has absolutely zero effect on the computed hash.
|
|
$subjectNode = $assertion->getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Subject')->item(0);
|
|
|
|
if ($subjectNode) {
|
|
$assertion->insertBefore($objDSig->sigNode, $subjectNode);
|
|
Log::debug('SamlIdpService: Re-ordered nodes: Signature is now placed before Subject.');
|
|
} else {
|
|
Log::warning('SamlIdpService: Subject node not found; leaving signature appended at end.');
|
|
}
|
|
|
|
} catch (Exception $e) {
|
|
Log::error('SamlIdpService: Cryptographic signing failed.', [
|
|
'message' => $e->getMessage(),
|
|
'trace' => $e->getTraceAsString(),
|
|
]);
|
|
throw new RuntimeException('SAML assertion signing failed.', code: 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load IdP Private Key.
|
|
*
|
|
* @throws RuntimeException
|
|
*/
|
|
private function getPrivateKey(): string
|
|
{
|
|
$path = storage_path('app/saml/idp.key');
|
|
Log::debug('SamlIdpService: Loading private key.', ['path' => $path]);
|
|
try {
|
|
$key = File::get($path);
|
|
Log::debug('SamlIdpService: Private key loaded successfully.', [
|
|
'path' => $path,
|
|
'length' => mb_strlen($key),
|
|
]);
|
|
|
|
return $key;
|
|
} catch (Exception $e) {
|
|
Log::error("SamlIdpService: SAML private key not found at [$path].", [
|
|
'path' => $path,
|
|
'message' => $e->getMessage(),
|
|
]);
|
|
throw new RuntimeException("SAML private key not found at [$path].", code: 500);
|
|
}
|
|
}
|
|
}
|