singleloginsystem/app/Services/SamlIdpService.php
= 57f0a4590c Feat: Add comprehensive support for SAML SSO integration
- Introduced `ddev` configuration for Laravel-based SAML SSO setup.
- Added detailed technical specifications for SAML 2.0 integration, including endpoints, flows, and cryptographic signing.
- Created extensive unit tests to validate `SamlIdpController` and `SamlIdpService` functionalities.
- Enhanced metadata generation, SAML request parsing, and cryptographic signing of responses.
- Implemented models, services, and tests to standardize IdP interactions with Service Providers.
2026-06-01 11:04:16 +00:00

380 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
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
{
$decoded = base64_decode($samlRequest, true);
if (false === $decoded) {
throw new Exception('Failed to base64 decode SAMLRequest.');
}
// Try deflated (GET Redirect binding), fallback to raw XML (POST binding)
$xml = @gzinflate($decoded) ?: $decoded;
$dom = new DOMDocument();
$dom->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(<<<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);
}
/**
* 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);
}
}
}