- 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.
123 lines
4.9 KiB
PHP
123 lines
4.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Unit;
|
|
|
|
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);
|
|
|
|
use App\Models\{ConnectedApp, ConnectionProtocol, ConnectionProvider, ConnectionStatus, User};
|
|
use App\Services\SamlIdpService;
|
|
use Exception;
|
|
use Illuminate\Support\Facades\File;
|
|
|
|
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(\Database\Seeders\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',
|
|
],
|
|
],
|
|
]);
|
|
|
|
$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
|
|
});
|