- 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.
201 lines
6.9 KiB
PHP
201 lines
6.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Unit;
|
|
|
|
uses(\Tests\TestCase::class);
|
|
|
|
use App\Http\Controllers\SamlIdpController;
|
|
use App\Models\{ConnectedApp, ConnectionStatus, User};
|
|
use App\Services\SamlIdpService;
|
|
use Exception;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Mockery;
|
|
|
|
beforeEach(function (): void {
|
|
$this->mockService = Mockery::mock(SamlIdpService::class);
|
|
$this->controller = new SamlIdpController($this->mockService);
|
|
});
|
|
|
|
afterEach(function (): void {
|
|
Mockery::close();
|
|
});
|
|
|
|
test('sso throws 400 bad request when SAMLRequest is empty', function (): void {
|
|
$request = Request::create('/saml/sso', 'GET');
|
|
|
|
expect(fn () => $this->controller->sso($request))
|
|
->toThrow(\Symfony\Component\HttpKernel\Exception\HttpException::class, 'Missing SAMLRequest parameter.');
|
|
});
|
|
|
|
test('sso throws 400 bad request when SAMLRequest fails to parse', function (): void {
|
|
$request = Request::create('/saml/sso', 'GET', ['SAMLRequest' => 'invalid-base64']);
|
|
|
|
$this->mockService->shouldReceive('parseRequest')
|
|
->once()
|
|
->with('invalid-base64')
|
|
->andThrow(new Exception('SAML XML parsing error.'));
|
|
|
|
expect(fn () => $this->controller->sso($request))
|
|
->toThrow(\Symfony\Component\HttpKernel\Exception\HttpException::class, 'Invalid SAMLRequest: SAML XML parsing error.');
|
|
});
|
|
|
|
test('sso throws 403 forbidden when Service Provider is unregistered', function (): void {
|
|
$request = Request::create('/saml/sso', 'GET', ['SAMLRequest' => 'valid-request']);
|
|
|
|
$this->mockService->shouldReceive('parseRequest')
|
|
->once()
|
|
->with('valid-request')
|
|
->andReturn([
|
|
'requestId' => '_req_1',
|
|
'issuer' => 'urn:unregistered',
|
|
'acsUrl' => 'https://acs.url',
|
|
]);
|
|
|
|
$this->mockService->shouldReceive('findConnectedApp')
|
|
->once()
|
|
->with('urn:unregistered')
|
|
->andReturn(null);
|
|
|
|
expect(fn () => $this->controller->sso($request))
|
|
->toThrow(\Symfony\Component\HttpKernel\Exception\HttpException::class, 'The Service Provider [urn:unregistered] is not authorized in this system.');
|
|
});
|
|
|
|
test('sso throws 403 forbidden when Service Provider is registered but disconnected', function (): void {
|
|
$request = Request::create('/saml/sso', 'GET', ['SAMLRequest' => 'valid-request']);
|
|
|
|
$status = new ConnectionStatus();
|
|
$status->name = 'disconnected';
|
|
|
|
$app = new ConnectedApp();
|
|
$app->name = 'Test Disconnected App';
|
|
$app->setRelation('status', $status);
|
|
|
|
$this->mockService->shouldReceive('parseRequest')
|
|
->once()
|
|
->with('valid-request')
|
|
->andReturn([
|
|
'requestId' => '_req_1',
|
|
'issuer' => 'urn:disconnected',
|
|
'acsUrl' => 'https://acs.url',
|
|
]);
|
|
|
|
$this->mockService->shouldReceive('findConnectedApp')
|
|
->once()
|
|
->with('urn:disconnected')
|
|
->andReturn($app);
|
|
|
|
expect(fn () => $this->controller->sso($request))
|
|
->toThrow(\Symfony\Component\HttpKernel\Exception\HttpException::class, 'The Service Provider [Test Disconnected App] is currently deactivated.');
|
|
});
|
|
|
|
test('sso redirects guest user to login page and stores SAML parameters in session', function (): void {
|
|
$request = Request::create('/saml/sso', 'GET', [
|
|
'SAMLRequest' => 'valid-request',
|
|
'RelayState' => 'https://relay.state',
|
|
]);
|
|
|
|
$status = new ConnectionStatus();
|
|
$status->name = 'connected';
|
|
|
|
$app = new ConnectedApp();
|
|
$app->name = 'Active SAML App';
|
|
$app->setRelation('status', $status);
|
|
$app->settings = ['saml' => ['acs_url' => 'https://acs.url']];
|
|
|
|
$this->mockService->shouldReceive('parseRequest')
|
|
->once()
|
|
->with('valid-request')
|
|
->andReturn([
|
|
'requestId' => '_req_1',
|
|
'issuer' => 'urn:active',
|
|
'acsUrl' => 'https://acs.url',
|
|
]);
|
|
|
|
$this->mockService->shouldReceive('findConnectedApp')
|
|
->once()
|
|
->with('urn:active')
|
|
->andReturn($app);
|
|
|
|
// Mock guest Auth state
|
|
Auth::shouldReceive('check')->once()->andReturn(false);
|
|
|
|
$response = $this->controller->sso($request);
|
|
|
|
expect($response->isRedirect(route('login')))->toBeTrue()
|
|
->and(session('saml_pending_request'))->toBe('valid-request')
|
|
->and(session('saml_pending_relay_state'))->toBe('https://relay.state');
|
|
});
|
|
|
|
test('sso generates SAML Response and auto-submitting POST view for authenticated user', function (): void {
|
|
$request = Request::create('/saml/sso', 'GET', [
|
|
'SAMLRequest' => 'valid-request',
|
|
'RelayState' => 'https://relay.state',
|
|
]);
|
|
|
|
$status = new ConnectionStatus();
|
|
$status->name = 'connected';
|
|
|
|
$app = new ConnectedApp();
|
|
$app->name = 'Active SAML App';
|
|
$app->setRelation('status', $status);
|
|
$app->settings = ['saml' => ['acs_url' => 'https://acs.url']];
|
|
|
|
$user = new User();
|
|
$user->email = 'activeuser@sso.local';
|
|
|
|
$this->mockService->shouldReceive('parseRequest')
|
|
->once()
|
|
->with('valid-request')
|
|
->andReturn([
|
|
'requestId' => '_req_1',
|
|
'issuer' => 'urn:active',
|
|
'acsUrl' => 'https://acs.url',
|
|
]);
|
|
|
|
$this->mockService->shouldReceive('findConnectedApp')
|
|
->once()
|
|
->with('urn:active')
|
|
->andReturn($app);
|
|
|
|
// Mock authenticated Auth state
|
|
Auth::shouldReceive('check')->once()->andReturn(true);
|
|
Auth::shouldReceive('user')->once()->andReturn($user);
|
|
|
|
$this->mockService->shouldReceive('generateResponse')
|
|
->once()
|
|
->with($user, $app, '_req_1', 'https://acs.url')
|
|
->andReturn('base64-signed-saml-response-xml-payload');
|
|
|
|
// Populate session to assert it gets cleared
|
|
session([
|
|
'saml_pending_request' => 'valid-request',
|
|
'saml_pending_relay_state' => 'https://relay.state',
|
|
]);
|
|
|
|
$view = $this->controller->sso($request);
|
|
|
|
expect($view->name())->toBe('saml.post_response')
|
|
->and($view->getData()['appName'])->toBe('Active SAML App')
|
|
->and($view->getData()['acsUrl'])->toBe('https://acs.url')
|
|
->and($view->getData()['samlResponse'])->toBe('base64-signed-saml-response-xml-payload')
|
|
->and($view->getData()['relayState'])->toBe('https://relay.state')
|
|
->and(session('saml_pending_request'))->toBeNull()
|
|
->and(session('saml_pending_relay_state'))->toBeNull();
|
|
});
|
|
|
|
test('metadata returns XML response containing clean endpoints', function (): void {
|
|
$this->mockService->shouldReceive('generateMetadata')
|
|
->once()
|
|
->with(route('saml.metadata'), route('saml.sso'))
|
|
->andReturn('<EntityDescriptor></EntityDescriptor>');
|
|
|
|
$response = $this->controller->metadata();
|
|
|
|
expect($response->getStatusCode())->toBe(200)
|
|
->and($response->headers->get('Content-Type'))->toBe('application/xml; charset=utf-8')
|
|
->and($response->getContent())->toBe('<EntityDescriptor></EntityDescriptor>');
|
|
});
|