preserveWhiteSpace = false; if (! @$dom->loadXML($xml)) { throw new Exception('Failed to load SAMLRequest XML.'); } $issuerNode = $dom ->getElementsByTagNameNS( 'urn:oasis:names:tc:SAML:2.0:assertion', 'Issuer', ) ->item(0); if (! $issuerNode) { $issuerNode = $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); $xml = << {$certClean} XML; return $xml; } /** * 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(); $now = time(); $issueInstant = gmdate("Y-m-d\TH:i:s\Z", $now); $notBefore = gmdate("Y-m-d\TH:i:s\Z", $now - 30); // 30 sec skew tolerance $expireInstant = gmdate("Y-m-d\TH:i:s\Z", $now + 300); // 5 min validity $immutableId = $user->immutable_id ?? base64_encode((string) $user->id); $dom = new DOMDocument('1.0', 'UTF-8'); $dom->preserveWhiteSpace = false; $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', $issueInstant); $response->setAttribute('Destination', $acsUrl); $issuer = $dom->createElementNS( 'urn:oasis:names:tc:SAML:2.0:assertion', 'saml:Issuer', $idpEntityId, ); $response->appendChild($issuer); $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); $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', $issueInstant); $assertionIssuer = $dom->createElement('saml:Issuer', $idpEntityId); $assertion->appendChild($assertionIssuer); // Subject $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', ); $subjectConfirmationData = $dom->createElement( 'saml:SubjectConfirmationData', ); $subjectConfirmationData->setAttribute('InResponseTo', $requestId); $subjectConfirmationData->setAttribute('NotOnOrAfter', $expireInstant); $subjectConfirmationData->setAttribute('Recipient', $acsUrl); $subjectConfirmation->appendChild($subjectConfirmationData); $subject->appendChild($subjectConfirmation); $assertion->appendChild($subject); // Conditions $conditions = $dom->createElement('saml:Conditions'); $conditions->setAttribute('NotBefore', $notBefore); $conditions->setAttribute('NotOnOrAfter', $expireInstant); $audienceRestriction = $dom->createElement('saml:AudienceRestriction'); $audience = $dom->createElement('saml:Audience', $spEntityId); $audienceRestriction->appendChild($audience); $conditions->appendChild($audienceRestriction); $assertion->appendChild($conditions); // AuthnStatement $authnStatement = $dom->createElement('saml:AuthnStatement'); $authnStatement->setAttribute('AuthnInstant', $issueInstant); $authnStatement->setAttribute('SessionIndex', $assertionId); $authnContext = $dom->createElement('saml:AuthnContext'); $authnContextClassRef = $dom->createElement( 'saml:AuthnContextClassRef', 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport', ); $authnContext->appendChild($authnContextClassRef); $authnStatement->appendChild($authnContext); $assertion->appendChild($authnStatement); // AttributeStatement (SAML claims) $attributeStatement = $dom->createElement('saml:AttributeStatement'); // IDPEmail (essential claim for Office 365 mapping to UserPrincipalName) $this->addAttribute( $dom, $attributeStatement, 'IDPEmail', $user->email, ); // mail $this->addAttribute($dom, $attributeStatement, 'mail', $user->email); // UPN $this->addAttribute( $dom, $attributeStatement, 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn', $user->email, ); // displayName $this->addAttribute( $dom, $attributeStatement, 'displayName', $user->name, ); // emailaddress (Required by Microsoft SAML 2.0 specifications) $this->addAttribute( $dom, $attributeStatement, 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', $user->email, ); $assertion->appendChild($attributeStatement); $response->appendChild($assertion); $dom->appendChild($response); // Sign the assertion node $this->signAssertion($dom, $assertion); return base64_encode($dom->saveXML()); } /** * Add a simple attribute to SAML AttributeStatement. */ 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 { $privateKeyPem = $this->getPrivateKey(); $certPem = $this->getPublicCertificate(); // XML Security Signature Setup $objDSig = new XMLSecurityDSig(); $objDSig->setCanonicalMethod(XMLSecurityDSig::C14N); // Explicitly designate Assertion ID attribute so xmlseclibs can locate it correctly $assertion->setIdAttribute('ID', true); // Add reference to assertion $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, false); // Sign the XML $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); } } /** * Load IdP Private Key. */ private function getPrivateKey(): string { $path = storage_path('app/saml/idp.key'); if (! File::exists($path)) { throw new RuntimeException( "SAML private key not found at [{$path}]. Run 'php artisan saml:generate-keys'.", ); } return File::get($path); } /** * Load IdP Public Certificate. */ private function getPublicCertificate(): string { $path = storage_path('app/saml/idp.crt'); if (! File::exists($path)) { throw new RuntimeException( "SAML public certificate not found at [{$path}]. Run 'php artisan saml:generate-keys'.", ); } return File::get($path); } /** * 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, ); } }