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(<< {$certClean} 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 $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 $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); } } }