preserveWhiteSpace = false; // Securely handle XML loading without using '@' suppression libxml_use_internal_errors(true); $loaded = $dom->loadXML($xml); libxml_clear_errors(); if (! $loaded) { 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'); 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 { return ConnectedApp::query() ->where('settings->saml->entity_id', $issuer) ->first(); } /** * Generate the XML SAML metadata for this Identity Provider. */ public function generateMetadata(string $idpEntityId, string $ssoUrl): string { $certPem = $this->getPublicCertificate(); $certClean = $this->cleanPem($certPem); return mb_trim(<< {$certClean} XML); } /** * Load IdP Public Certificate. */ private function getPublicCertificate(): string { $path = storage_path('app/saml/idp.crt'); try { return File::get($path); } catch (Exception $e) { Log::error("SAML public certificate not found at [$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(); // 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), ]; $dom = new DOMDocument('1.0', 'UTF-8'); $dom->preserveWhiteSpace = false; // 1. Create Base Response $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->createElement('samlp:Status'); $statusCode = $dom->createElement('samlp:StatusCode'); $statusCode->setAttribute('Value', 'urn:oasis:names:tc:SAML:2.0:status:Success'); $status->appendChild($statusCode); $response->appendChild($status); // 2. Create Assertion $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->createElement('saml:Issuer', $idpEntityId)); // 3. Build Assertion Sub-components $this->buildSubject($dom, $assertion, $user, $requestId, $acsUrl, $timestamps['expire']); $this->buildConditions($dom, $assertion, $spEntityId, $timestamps['notBefore'], $timestamps['expire']); $this->buildAuthnStatement($dom, $assertion, $timestamps['issue'], $assertionId); $this->buildAttributeStatement($dom, $assertion, $user); $response->appendChild($assertion); $dom->appendChild($response); // 4. Sign Assertion & Return Base64 payload $this->signAssertion($dom, $assertion); return base64_encode($dom->saveXML()); } /** * Build the SAML Subject clause. * * @throws DOMException */ private function buildSubject( DOMDocument $dom, DOMElement $assertion, User $user, string $requestId, string $acsUrl, string $expireInstant ): void { $immutableId = $user->immutable_id ?? base64_encode((string) $user->id); $subject = $dom->createElement('saml:Subject'); $nameId = $dom->createElement('saml:NameID', $immutableId); $nameId->setAttribute('Format', 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'); $subject->appendChild($nameId); $subjectConfirmation = $dom->createElement('saml:SubjectConfirmation'); $subjectConfirmation->setAttribute('Method', 'urn:oasis:names:tc:SAML:2.0:cm:bearer'); $confirmationData = $dom->createElement('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 { $conditions = $dom->createElement('saml:Conditions'); $conditions->setAttribute('NotBefore', $notBefore); $conditions->setAttribute('NotOnOrAfter', $expireInstant); $audienceRestriction = $dom->createElement('saml:AudienceRestriction'); $audienceRestriction->appendChild($dom->createElement('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 { $authnStatement = $dom->createElement('saml:AuthnStatement'); $authnStatement->setAttribute('AuthnInstant', $issueInstant); $authnStatement->setAttribute('SessionIndex', $assertionId); $authnContext = $dom->createElement('saml:AuthnContext'); $authnContext->appendChild( $dom->createElement( '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): void { $statement = $dom->createElement('saml:AttributeStatement'); $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, ]; foreach ($claims as $name => $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 { $attribute = $dom->createElement('saml:Attribute'); $attribute->setAttribute('Name', $name); $attribute->setAttribute('NameFormat', 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic'); $val = $dom->createElement('saml:AttributeValue', htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8')); $attribute->appendChild($val); $statement->appendChild($attribute); } /** * Cryptographically sign the Assertion node in DOMDocument. */ private function signAssertion(DOMDocument $dom, DOMElement|DOMDocument $assertion): void { try { $privateKeyPem = $this->getPrivateKey(); $certPem = $this->getPublicCertificate(); $objDSig = new XMLSecurityDSig(); $objDSig->setCanonicalMethod(XMLSecurityDSig::C14N); $assertion->setIdAttribute('ID', true); $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'] ); $objKey = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, ['type' => 'private']); $objKey->loadKey($privateKeyPem); $objDSig->sign($objKey, $assertion); $objDSig->add509Cert($certPem); // Enforce SAML Schema order compliance: Signature node must precede Subject. $signatureNode = $assertion->getElementsByTagNameNS( 'http://www.w3.org/2000/09/xmldsig#', 'Signature' )->item(0); $subjectNode = $assertion->getElementsByTagNameNS( 'urn:oasis:names:tc:SAML:2.0:assertion', 'Subject' )->item(0); if ($signatureNode && $subjectNode) { $assertion->insertBefore($signatureNode, $subjectNode); } } catch (Exception $e) { Log::error('SAML assertion signing failed.', ['message' => $e->getMessage()]); 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'); try { return File::get($path); } catch (Exception $e) { Log::error("SAML private key not found at [$path].", ['message' => $e->getMessage()]); throw new RuntimeException("SAML private key not found at [$path].", code: 500); } } }