- 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.
257 lines
11 KiB
PHP
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"');
|
|
});
|