- 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.
168 lines
6.4 KiB
PHP
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);
|
|
});
|