singleloginsystem/tests/Feature/SamlIdpTest.php
= aaa258903c Feat: Add enhanced SAML configuration and attribute mapping support
- Implemented `EnumMorphsToOptionsContract` interface to enable enums with options and labels for dropdowns.
- Created `SamlNameIdFormatEnum` and `SamlNameIdSourceEnum` for standardized SAML NameID configuration.
- Added reusable `SAML Configuration` Blade component for easier integration into connected app forms.
- Enabled custom SAML Attribute mapping with dynamic add/remove functionality and validation.
- Improved `SamlIdpController` to enforce ACS URL matching and user authorization checks.
- Refactored SAML-related tests and added scenarios for role-based SAML access and custom configurations.
2026-06-08 12:56:28 +00:00

257 lines
11 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\{ConnectedApp, ConnectionProtocol, ConnectionProvider, ConnectionStatus, User};
use Illuminate\Support\Facades\File;
use Livewire\Livewire;
beforeEach(function (): void {
// Generate testing keys if not already present
$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);
// Get seeded protocol, provider, and status
$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();
// Create a mock SAML App
$this->samlApp = ConnectedApp::query()->create([
'name' => 'Microsoft Office 365',
'slug' => 'microsoft-office-365',
'connection_protocol_id' => $this->protocol->id,
'connection_provider_id' => $this->provider->id,
'connection_status_id' => $this->status->id,
'settings' => [
'saml' => [
'entity_id' => 'urn:federation:MicrosoftOnline',
'acs_url' => 'https://login.microsoftonline.com/login.srf',
],
],
]);
// Create role and attach the SAML app
$this->samlRole = App\Models\Role::findOrCreate('SAML User');
$this->samlRole->update(['priority' => 10]);
$this->samlRole->apps()->attach($this->samlApp->id, ['duration' => now()->addYear()->toDateTimeString()]);
// Create a mock User and assign the role
$this->user = User::factory()->create([
'email' => 'testuser@company.com',
'immutable_id' => 'user-123-immutable',
]);
$this->user->assignRole($this->samlRole);
});
test('saml metadata endpoint returns active public certificate and sso location', function (): void {
$response = $this->get(route('saml.metadata'));
$response->assertStatus(200);
$response->assertHeader('Content-Type', 'application/xml; charset=utf-8');
$content = $response->getContent();
expect($content)->toContain('EntityDescriptor')
->toContain('IDPSSODescriptor')
->toContain('SingleSignOnService')
->toContain(route('saml.sso'))
->toContain('X509Certificate');
});
test('saml sso endpoint aborts if missing SAMLRequest', function (): void {
$response = $this->get(route('saml.sso'));
$response->assertStatus(400);
});
test('saml sso endpoint redirects unauthenticated users to login page and remembers request', 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="_mock_request_id" Version="2.0" IssueInstant="2026-05-26T12:00:00Z" AssertionConsumerServiceURL="https://login.microsoftonline.com/login.srf" Destination="http://localhost/saml/sso"><saml:Issuer>urn:federation:MicrosoftOnline</saml:Issuer></samlp:AuthnRequest>';
$samlRequest = base64_encode(gzdeflate($xml));
$relayState = 'https://teams.microsoft.com/';
$response = $this->get(route('saml.sso', [
'SAMLRequest' => $samlRequest,
'RelayState' => $relayState,
]));
$response->assertRedirect(route('login'));
// Assert session retains pending details
expect(session('saml_pending_request'))->toBe($samlRequest);
expect(session('saml_pending_relay_state'))->toBe($relayState);
});
test('saml sso endpoint signs and redirects authenticated users via POST binding', 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="_mock_request_id" Version="2.0" IssueInstant="2026-05-26T12:00:00Z" AssertionConsumerServiceURL="https://login.microsoftonline.com/login.srf" Destination="http://localhost/saml/sso"><saml:Issuer>urn:federation:MicrosoftOnline</saml:Issuer></samlp:AuthnRequest>';
$samlRequest = base64_encode(gzdeflate($xml));
$relayState = 'https://teams.microsoft.com/';
// Log in the user
$this->actingAs($this->user);
$response = $this->get(route('saml.sso', [
'SAMLRequest' => $samlRequest,
'RelayState' => $relayState,
]));
$response->assertStatus(200);
$response->assertViewIs('saml.post_response');
$response->assertViewHasAll([
'appName' => 'Microsoft Office 365',
'acsUrl' => 'https://login.microsoftonline.com/login.srf',
'relayState' => $relayState,
]);
$content = $response->getContent();
expect($content)->toContain('name="SAMLResponse"')
->toContain('name="RelayState"')
->toContain('value="'.$relayState.'"');
// Verify session was cleared
expect(session('saml_pending_request'))->toBeNull();
});
test('user sso settings and immutable id can be assigned via service and mapped to DTO', function (): void {
$userService = app(App\Services\UserService::class);
// Assign roles and update immutable ID
$userService->assignRoles($this->user->id, [], 'new-custom-sso-immutable-id');
// Retrieve fresh user and assert
$freshUser = $this->user->fresh();
expect($freshUser->immutable_id)->toBe('new-custom-sso-immutable-id');
// Assert UserIndexData maps it correctly
$dto = App\Data\User\UserIndexData::fromModel($freshUser);
expect($dto->immutableId)->toBe('new-custom-sso-immutable-id');
});
test('saml settings field is only visible if selected roles contain a SAML app', function (): void {
// Create a role with a SAML connected app
$samlRole = App\Models\Role::findOrCreate('SAML Settings User');
$samlRole->update(['priority' => 10]);
$samlRole->apps()->attach($this->samlApp->id, ['duration' => now()->addYear()->toDateTimeString()]);
// Create a role with no connected apps
$emptyRole = App\Models\Role::findOrCreate('Empty User');
$emptyRole->update(['priority' => 20]);
// Log in the administrator so they can view users
$admin = User::where('email', 'admin@example.com')->firstOrFail();
$this->actingAs($admin);
// 1. Livewire component with no roles selected should return false for showSamlSettings
Livewire::test('pages::access-manager.users.index')
->set('form.roleIds', [])
->assertSet('showSamlSettings', false);
// 2. Livewire component with emptyRole selected should return false
Livewire::test('pages::access-manager.users.index')
->set('form.roleIds', [$emptyRole->id])
->assertSet('showSamlSettings', false);
// 3. Livewire component with samlRole selected should return true
Livewire::test('pages::access-manager.users.index')
->set('form.roleIds', [$samlRole->id])
->assertSet('showSamlSettings', true);
});
test('saml sso endpoint aborts with 403 if authenticated user is not authorized for the app', 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="_mock_request_id" Version="2.0" IssueInstant="2026-05-26T12:00:00Z" AssertionConsumerServiceURL="https://login.microsoftonline.com/login.srf" Destination="http://localhost/saml/sso"><saml:Issuer>urn:federation:MicrosoftOnline</saml:Issuer></samlp:AuthnRequest>';
$samlRequest = base64_encode(gzdeflate($xml));
$relayState = 'https://teams.microsoft.com/';
// Create a user without the SAML role
$unauthorizedUser = User::factory()->create([
'email' => 'unauthorized@company.com',
]);
$this->actingAs($unauthorizedUser);
$response = $this->get(route('saml.sso', [
'SAMLRequest' => $samlRequest,
'RelayState' => $relayState,
]));
$response->assertStatus(403);
});
test('saml sso endpoint aborts with 400 if request ACS URL does not match configured ACS URL', 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="_mock_request_id" Version="2.0" IssueInstant="2026-05-26T12:00:00Z" AssertionConsumerServiceURL="https://malicious.com/callback" Destination="http://localhost/saml/sso"><saml:Issuer>urn:federation:MicrosoftOnline</saml:Issuer></samlp:AuthnRequest>';
$samlRequest = base64_encode(gzdeflate($xml));
$relayState = 'https://teams.microsoft.com/';
$this->actingAs($this->user);
$response = $this->get(route('saml.sso', [
'SAMLRequest' => $samlRequest,
'RelayState' => $relayState,
]));
$response->assertStatus(400);
});
test('saml sso endpoint respects custom NameID configuration and attributes mapping', function (): void {
// Modify settings on the connected app for custom configuration
$this->samlApp->update([
'settings' => [
'saml' => [
'entity_id' => 'urn:federation:MicrosoftOnline',
'acs_url' => 'https://login.microsoftonline.com/login.srf',
'nameid_format' => 'emailAddress',
'nameid_source' => 'email',
'attributes' => [
'customMail' => 'email',
'customName' => 'name',
'customId' => 'id',
],
],
],
]);
$xml = '<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_mock_request_id" Version="2.0" IssueInstant="2026-05-26T12:00:00Z" AssertionConsumerServiceURL="https://login.microsoftonline.com/login.srf" Destination="http://localhost/saml/sso"><saml:Issuer>urn:federation:MicrosoftOnline</saml:Issuer></samlp:AuthnRequest>';
$samlRequest = base64_encode(gzdeflate($xml));
$relayState = 'https://teams.microsoft.com/';
$this->actingAs($this->user);
$response = $this->get(route('saml.sso', [
'SAMLRequest' => $samlRequest,
'RelayState' => $relayState,
]));
$response->assertStatus(200);
$response->assertViewIs('saml.post_response');
$content = $response->getContent();
$samlResponseBase64 = null;
if (preg_match('/name="SAMLResponse" value="([^"]+)"/', $content, $matches)) {
$samlResponseBase64 = $matches[1];
}
expect($samlResponseBase64)->not->toBeNull();
$xmlResponse = base64_decode($samlResponseBase64, true);
// Assert custom NameID format is used
expect($xmlResponse)->toContain('Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"');
// Assert user email is used as NameID value
expect($xmlResponse)->toContain($this->user->email);
// Assert custom mapped claims are in the response
expect($xmlResponse)->toContain('Name="customMail"')
->toContain('Name="customName"')
->toContain('Name="customId"');
// Assert default claims are NOT in the response since custom mappings are defined
expect($xmlResponse)->not->toContain('Name="IDPEmail"')
->not->toContain('Name="displayName"');
});