singleloginsystem/tests/Unit/SamlIdpServiceTest.php
= 2140c12b10 WIP: Improve SAML response generation and attribute mapping
- 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.
2026-06-08 13:20:01 +00:00

168 lines
6.4 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Enums\{SamlNameIdFormatEnum, SamlNameIdSourceEnum};
use App\Models\{ConnectedApp, ConnectionProtocol, ConnectionProvider, ConnectionStatus, User};
use App\Services\SamlIdpService;
use Database\Seeders\DatabaseSeeder;
use DOMDocument;
use Exception;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function (): void {
$this->service = new SamlIdpService();
// Ensure cryptographic keys exist for the tests
$directory = storage_path('app/saml');
if (! File::exists($directory)) {
File::makeDirectory($directory, 0755, true);
}
if (! File::exists("{$directory}/idp.key") || ! File::exists("{$directory}/idp.crt")) {
$this->artisan('saml:generate-keys');
}
$this->seed(DatabaseSeeder::class);
$this->protocol = ConnectionProtocol::query()->where('name', 'saml')->firstOrFail();
$this->provider = ConnectionProvider::query()->where('slug', 'azure-fd')->firstOrFail();
$this->status = ConnectionStatus::query()->where('name', 'connected')->firstOrFail();
$this->connectedApp = ConnectedApp::query()->create([
'name' => 'Test SAML App',
'slug' => 'test-saml-app',
'connection_protocol_id' => $this->protocol->id,
'connection_provider_id' => $this->provider->id,
'connection_status_id' => $this->status->id,
'settings' => [
'saml' => [
'entity_id' => 'urn:federation:TestApp',
'acs_url' => 'https://test-app.com/saml/acs',
'nameid_format' => mb_strtolower(SamlNameIdFormatEnum::Persistent->name),
'nameid_source' => SamlNameIdSourceEnum::ImmutableId->value,
],
],
]);
$this->user = User::factory()->create([
'email' => 'unituser@sso.local',
'immutable_id' => 'user-unit-test-123',
]);
});
test('parseRequest correctly decodes and parses deflated GET SAMLRequest', function (): void {
$xml = '<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_req_id" Version="2.0" AssertionConsumerServiceURL="https://test-app.com/saml/acs"><saml:Issuer>urn:federation:TestApp</saml:Issuer></samlp:AuthnRequest>';
$samlRequest = base64_encode(gzdeflate($xml));
$parsed = $this->service->parseRequest($samlRequest);
expect($parsed)->toBeArray()
->and($parsed['requestId'])->toBe('_req_id')
->and($parsed['issuer'])->toBe('urn:federation:TestApp')
->and($parsed['acsUrl'])->toBe('https://test-app.com/saml/acs');
});
test('parseRequest correctly decodes and parses raw POST SAMLRequest', function (): void {
$xml = '<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_req_id_post" Version="2.0" AssertionConsumerServiceURL="https://test-app.com/saml/acs"><saml:Issuer>urn:federation:TestApp</saml:Issuer></samlp:AuthnRequest>';
$samlRequest = base64_encode($xml);
$parsed = $this->service->parseRequest($samlRequest);
expect($parsed)->toBeArray()
->and($parsed['requestId'])->toBe('_req_id_post')
->and($parsed['issuer'])->toBe('urn:federation:TestApp')
->and($parsed['acsUrl'])->toBe('https://test-app.com/saml/acs');
});
test('parseRequest throws exception on invalid XML or encoding', function (): void {
expect(fn () => $this->service->parseRequest('invalid-base64-string!@#'))
->toThrow(Exception::class);
});
test('findConnectedApp resolves matched active SAML ConnectedApp model', function (): void {
$found = $this->service->findConnectedApp('urn:federation:TestApp');
expect($found)->not->toBeNull()
->and($found->id)->toBe($this->connectedApp->id)
->and($found->name)->toBe('Test SAML App');
});
test('findConnectedApp returns null if SP EntityID is unregistered', function (): void {
$found = $this->service->findConnectedApp('urn:unregistered-entity');
expect($found)->toBeNull();
});
test('generateMetadata returns valid XML schema with certificate', function (): void {
$xml = $this->service->generateMetadata('https://sso.local/metadata', 'https://sso.local/sso');
expect($xml)->toContain('EntityDescriptor')
->toContain('entityID="https://sso.local/metadata"')
->toContain('Location="https://sso.local/sso"')
->toContain('X509Certificate');
});
test('generateResponse builds cryptographically signed SAML Response', function (): void {
$samlResponseBase64 = $this->service->generateResponse(
$this->user,
$this->connectedApp,
'_req_id_123',
'https://test-app.com/saml/acs'
);
expect($samlResponseBase64)->toBeString();
$xml = base64_decode($samlResponseBase64, true);
expect($xml)->toContain('samlp:Response')
->toContain('InResponseTo="_req_id_123"')
->toContain('Destination="https://test-app.com/saml/acs"')
->toContain('saml:Assertion')
->toContain('Signature') // Cryptographically signed
->toContain('user-unit-test-123') // NameID (Immutable ID)
->toContain('unituser@sso.local'); // User email in claims
});
test('generated SAML Response signature can be cryptographically verified', function (): void {
$samlResponseBase64 = $this->service->generateResponse(
$this->user,
$this->connectedApp,
'_req_id_123',
'https://test-app.com/saml/acs'
);
$xml = base64_decode($samlResponseBase64, true);
$doc = new DOMDocument();
$doc->preserveWhiteSpace = true;
$doc->loadXML($xml);
$xmlSignature = new \RobRichards\XMLSecLibs\XMLSecurityDSig();
$xmlSignature->idKeys = ['ID'];
// Locate signature node in the document
$sigNode = $xmlSignature->locateSignature($doc);
expect($sigNode)->not->toBeNull();
// Validate reference
$xmlSignature->canonicalizeSignedInfo();
$refValidated = $xmlSignature->validateReference();
expect($refValidated)->toBeTrue();
// Locate the key and load the public key
$publicKey = $xmlSignature->locateKey();
expect($publicKey)->not->toBeNull();
$certPem = File::get(storage_path('app/saml/idp.crt'));
$publicKey->loadKey($certPem, false);
// Verify signature
$result = $xmlSignature->verify($publicKey);
expect($result)->toBe(1);
});