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.
This commit is contained in:
= 2026-06-08 12:56:28 +00:00
parent 215d3cad7f
commit aaa258903c
13 changed files with 468 additions and 50 deletions

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Contracts;
interface EnumMorphsToOptionsContract
{
public static function asOptions(): array;
public function toLabel(): string;
}

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use App\Contracts\EnumMorphsToOptionsContract;
enum SamlNameIdFormatEnum: string implements EnumMorphsToOptionsContract
{
case Persistent = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent';
case Email = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress';
case Transient = 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient';
case Unspecified = 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified';
// We will store enum values in the settings of the app, which will mapped to URN later.
public static function asOptions(): array
{
return array_map(fn (self $enum) => [
'name' => $enum->toLabel(),
'value' => mb_strtolower($enum->name),
], self::cases());
}
public function toLabel(): string
{
return match ($this) {
self::Persistent => 'Persistent',
self::Email => 'Email Address',
self::Transient => 'Transient',
self::Unspecified => 'Unspecified',
};
}
}

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use App\Contracts\EnumMorphsToOptionsContract;
enum SamlNameIdSourceEnum: string implements EnumMorphsToOptionsContract
{
case ImmutableId = 'immutable_id';
case UserIdLocal = 'id';
case Email = 'email';
public static function asOptions(): array
{
return array_map(
fn (self $enum) => [
'name' => $enum->toLabel(),
'value' => $enum->value,
],
self::cases()
);
}
public function toLabel(): string
{
return match ($this) {
self::ImmutableId => 'Immutable ID',
self::UserIdLocal => 'User ID (Local)',
self::Email => 'Email',
};
}
}

View File

@ -5,14 +5,17 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\User; use App\Models\User;
use App\Services\SamlIdpService; use App\Services\{SamlIdpService, UserAppAccessService};
use Illuminate\Http\{Request, Response}; use Illuminate\Http\{Request, Response};
use Illuminate\Support\Facades\{Auth, Log}; use Illuminate\Support\Facades\{Auth, Log};
use Throwable; use Throwable;
class SamlIdpController extends Controller class SamlIdpController extends Controller
{ {
public function __construct(private readonly SamlIdpService $samlService) {} public function __construct(
private readonly SamlIdpService $samlService,
private readonly UserAppAccessService $userAppAccessService
) {}
/** /**
* SAML 2.0 Single Sign-On (SSO) Endpoint. * SAML 2.0 Single Sign-On (SSO) Endpoint.
@ -95,11 +98,11 @@ public function sso(Request $request)
* } * }
* } $settings * } $settings
*/ */
$settings = $connectedApp->settings; $settings = (array) $connectedApp->settings;
$acsUrl = $parsed['acsUrl'] ?: $settings['saml']['acs_url'] ?? null; $configuredAcsUrl = $settings['saml']['acs_url'] ?? null;
if (empty($acsUrl)) { if (empty($configuredAcsUrl)) {
Log::error('SAML SSO failed: Assertion Consumer Service (ACS) URL is undefined.', [ Log::error('SAML SSO failed: Assertion Consumer Service (ACS) URL is undefined in Connected App settings.', [
'app_id' => $connectedApp->id, 'app_id' => $connectedApp->id,
'app_name' => $connectedApp->name, 'app_name' => $connectedApp->name,
'request_id' => $parsed['requestId'], 'request_id' => $parsed['requestId'],
@ -107,6 +110,23 @@ public function sso(Request $request)
abort(400, 'Assertion Consumer Service (ACS) URL is not defined.'); abort(400, 'Assertion Consumer Service (ACS) URL is not defined.');
} }
$requestAcsUrl = $parsed['acsUrl'];
if (! empty($requestAcsUrl)) {
if (mb_strtolower(urldecode($requestAcsUrl)) !== mb_strtolower(urldecode($configuredAcsUrl))) {
Log::warning('SAML SSO failed: Request ACS URL does not match configured ACS URL.', [
'app_id' => $connectedApp->id,
'app_name' => $connectedApp->name,
'request_acs_url' => $requestAcsUrl,
'configured_acs_url' => $configuredAcsUrl,
'request_id' => $parsed['requestId'],
]);
abort(400, 'The Assertion Consumer Service (ACS) URL does not match the configured callback URL.');
}
$acsUrl = $requestAcsUrl;
} else {
$acsUrl = $configuredAcsUrl;
}
// Authenticate the User // Authenticate the User
if (! Auth::check()) { if (! Auth::check()) {
Log::info('SAML SSO guest session intercepted. Persisting request parameters to session and redirecting to login.', [ Log::info('SAML SSO guest session intercepted. Persisting request parameters to session and redirecting to login.', [
@ -126,7 +146,18 @@ public function sso(Request $request)
/** @var User $user */ /** @var User $user */
$user = Auth::user(); $user = Auth::user();
Log::info('SAML SSO user authenticated session detected. Generating signed SAML Response assertion.', [ // Enforce user authorization check
$activeServices = $this->userAppAccessService->getActiveServices($user);
if (! $activeServices->contains('id', $connectedApp->id)) {
Log::warning('SAML SSO access rejected: User is not authorized for this Connected App.', [
'user_id' => $user->id,
'app_id' => $connectedApp->id,
'app_name' => $connectedApp->name,
]);
abort(403, 'You are not authorized to access this application.');
}
Log::info('SAML SSO user authenticated and authorized session detected. Generating signed SAML Response assertion.', [
'user_id' => $user->id, 'user_id' => $user->id,
'user_email' => $user->email, 'user_email' => $user->email,
'app_id' => $connectedApp->id, 'app_id' => $connectedApp->id,

View File

@ -5,6 +5,8 @@
namespace App\Livewire\Forms; namespace App\Livewire\Forms;
use App\Data\ConnectedApp\ConnectedAppData; use App\Data\ConnectedApp\ConnectedAppData;
use App\Enums\ConnectionProtocolEnum;
use App\Models\ConnectionProtocol;
use App\Services\ConnectedAppService; use App\Services\ConnectedAppService;
use Livewire\Form; use Livewire\Form;
@ -22,6 +24,12 @@ class StoreConnectedAppFrom extends Form
public string $samlAcsUrl = ''; public string $samlAcsUrl = '';
public string $samlNameIdFormat = 'persistent';
public string $samlNameIdSource = 'immutable_id';
public array $samlAttributes = [];
protected ConnectedAppService $service; protected ConnectedAppService $service;
public function boot(ConnectedAppService $service): void public function boot(ConnectedAppService $service): void
@ -43,10 +51,15 @@ public function rules(): array
'statusId' => 'required|numeric|min:1|exists:connection_statuses,id', 'statusId' => 'required|numeric|min:1|exists:connection_statuses,id',
]; ];
$protocol = \App\Models\ConnectionProtocol::find($this->protocolId); $protocol = ConnectionProtocol::query()->find($this->protocolId);
if ($protocol && 'saml' === mb_strtolower($protocol->name)) { if ($protocol && ConnectionProtocolEnum::SAML->value === mb_strtolower($protocol->name)) {
$rules['samlEntityId'] = 'required|string|min:3'; $rules['samlEntityId'] = 'required|string|min:3';
$rules['samlAcsUrl'] = 'required|url'; $rules['samlAcsUrl'] = 'required|url';
$rules['samlNameIdFormat'] = 'required|string|in:persistent,emailAddress,transient,unspecified';
$rules['samlNameIdSource'] = 'required|string|in:immutable_id,email,id';
$rules['samlAttributes'] = 'array';
$rules['samlAttributes.*.saml_name'] = 'required|string|min:1';
$rules['samlAttributes.*.user_field'] = 'required|string|in:email,name,immutable_id,id';
} }
return $rules; return $rules;
@ -64,5 +77,30 @@ public function set(ConnectedAppData $data): void
$this->statusId = $data->statusId; $this->statusId = $data->statusId;
$this->samlEntityId = $data->settings['saml']['entity_id'] ?? ''; $this->samlEntityId = $data->settings['saml']['entity_id'] ?? '';
$this->samlAcsUrl = $data->settings['saml']['acs_url'] ?? ''; $this->samlAcsUrl = $data->settings['saml']['acs_url'] ?? '';
$this->samlNameIdFormat = $data->settings['saml']['nameid_format'] ?? 'persistent';
$this->samlNameIdSource = $data->settings['saml']['nameid_source'] ?? 'immutable_id';
$this->samlAttributes = [];
$attributes = $data->settings['saml']['attributes'] ?? [];
foreach ($attributes as $samlName => $userField) {
$this->samlAttributes[] = [
'saml_name' => $samlName,
'user_field' => $userField,
];
}
}
public function addSamlAttribute(): void
{
$this->samlAttributes[] = [
'saml_name' => '',
'user_field' => 'email',
];
}
public function removeSamlAttribute(int $index): void
{
unset($this->samlAttributes[$index]);
$this->samlAttributes = array_values($this->samlAttributes);
} }
} }

View File

@ -420,7 +420,7 @@ public function connectFederation(
$errorMsg = $errorJson['message'] ?? 'Failed to create federation configuration.'; $errorMsg = $errorJson['message'] ?? 'Failed to create federation configuration.';
if ('Authorization_RequestDenied' === $errorCode) { if ('Authorization_RequestDenied' === $errorCode) {
$errorMsg = "Insufficient privileges to complete the operation."; $errorMsg = 'Insufficient privileges to complete the operation.';
} }
Log::error('MicrosoftGraphService: Federation configuration creation failed on Microsoft Entra ID.', [ Log::error('MicrosoftGraphService: Federation configuration creation failed on Microsoft Entra ID.', [
'status_code' => $response->status(), 'status_code' => $response->status(),

View File

@ -0,0 +1,60 @@
@php use App\Enums\SamlNameIdFormatEnum;use App\Enums\SamlNameIdSourceEnum; @endphp
@props(['form'])
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-gray-200 pt-4 mt-2">
<h3 class="font-semibold text-sm text-blue-600 md:col-span-2">SAML Configuration</h3>
<x-mary-input wire:model="form.samlEntityId" required :label="__('SAML Entity ID (Audience)')"
placeholder="e.g. urn:federation:MicrosoftOnline"/>
<x-mary-input wire:model="form.samlAcsUrl" required :label="__('SAML ACS URL (Callback)')"
placeholder="e.g. https://login.microsoftonline.com/login.srf"/>
<x-mary-select
wire:model="form.samlNameIdFormat"
:label="__('SAML NameID Format')"
:options="SamlNameIdFormatEnum::asOptions()"
option-value="value"
/>
<x-mary-select
wire:model="form.samlNameIdSource"
:label="__('SAML NameID Source')"
:options="SamlNameIdSourceEnum::asOptions()"
option-value="value"
/>
<div class="md:col-span-2 border-t border-gray-200 pt-4 mt-2">
<div class="flex justify-between items-center mb-2">
<h4 class="text-xs font-semibold text-blue-600">Attribute/Claims Mapping</h4>
<x-mary-button wire:click.prevent="addSamlAttribute" spinner label="Add Mapping" icon="lucide.plus"
class="btn-xs btn-outline btn-primary"/>
</div>
@if(empty($form->samlAttributes))
<p class="text-xs text-gray-400 italic">No custom attribute mapping defined. Default
Microsoft claims will be used.</p>
@else
<div class="space-y-2">
@foreach($form->samlAttributes as $index => $attr)
<div class="flex gap-2 items-center" wire:key="saml-attr-{{ $index }}">
<x-mary-input wire:model="form.samlAttributes.{{ $index }}.saml_name"
placeholder="SAML Claim Name (e.g. mail)"
class="input-sm flex-1"/>
<x-mary-select
wire:model="form.samlAttributes.{{ $index }}.user_field"
:options="[
['id' => 'email', 'name' => 'User Email'],
['id' => 'name', 'name' => 'User Name'],
['id' => 'immutable_id', 'name' => 'User Immutable ID'],
['id' => 'id', 'name' => 'User ID (Local)']
]"
class="select-sm w-44"
/>
<x-mary-button
wire:click.prevent="removeSamlAttribute({{ $index }})"
spinner
icon="lucide.trash-2"
class="btn-sm btn-error btn-circle btn-soft"
/>
</div>
@endforeach
</div>
@endif
</div>
</div>

View File

@ -18,7 +18,8 @@
use Mary\Traits\Toast; use Mary\Traits\Toast;
use Spatie\LaravelData\DataCollection; use Spatie\LaravelData\DataCollection;
new #[Title("Create a service")] class extends Component { new #[Title("Create a service")]
class extends Component {
use HandlesOperations; use HandlesOperations;
public StoreConnectedAppFrom $form; public StoreConnectedAppFrom $form;
@ -82,10 +83,19 @@ public function save(bool $goBack = true): void
$settings = null; $settings = null;
if ($this->isSaml()) { if ($this->isSaml()) {
$attributes = [];
foreach ($this->form->samlAttributes as $attr) {
if (!empty($attr['saml_name']) && !empty($attr['user_field'])) {
$attributes[$attr['saml_name']] = $attr['user_field'];
}
}
$settings = [ $settings = [
"saml" => [ "saml" => [
"entity_id" => $this->form->samlEntityId, "entity_id" => $this->form->samlEntityId,
"acs_url" => $this->form->samlAcsUrl, "acs_url" => $this->form->samlAcsUrl,
"nameid_format" => $this->form->samlNameIdFormat,
"nameid_source" => $this->form->samlNameIdSource,
"attributes" => $attributes,
], ],
]; ];
} }
@ -109,7 +119,15 @@ public function save(bool $goBack = true): void
); );
} }
public function create() {} public function addSamlAttribute(): void
{
$this->form->addSamlAttribute();
}
public function removeSamlAttribute(int $index): void
{
$this->form->removeSamlAttribute($index);
}
public function goBack(): void public function goBack(): void
{ {
@ -148,11 +166,7 @@ public function goBack(): void
/> />
@if($this->isSaml) @if($this->isSaml)
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-gray-700/50 pt-4 mt-2"> <x-dashboard.connected-apps.saml-configuration :form="$this->form"/>
<h3 class="font-semibold text-sm text-sky-400 md:col-span-2">SAML Configuration</h3>
<x-mary-input wire:model="form.samlEntityId" required :label="__('SAML Entity ID (Audience)')" placeholder="e.g. urn:federation:MicrosoftOnline" />
<x-mary-input wire:model="form.samlAcsUrl" required :label="__('SAML ACS URL (Callback)')" placeholder="e.g. https://login.microsoftonline.com/login.srf" />
</div>
@endif @endif
<div class="md:col-span-2 flex gap-x-4 font-medium justify-end"> <div class="md:col-span-2 flex gap-x-4 font-medium justify-end">

View File

@ -19,10 +19,13 @@
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use Mary\Traits\Toast; use Mary\Traits\Toast;
use Spatie\LaravelData\DataCollection; use Spatie\LaravelData\DataCollection;
/** /**
* @property $protocols * @property $protocols
*/ */
new #[Lazy] #[Title("Edit a connected app")] class extends Component { new #[Lazy]
#[Title("Edit a connected app")]
class extends Component {
use HandlesOperations; use HandlesOperations;
public StoreConnectedAppFrom $form; public StoreConnectedAppFrom $form;
@ -97,10 +100,19 @@ public function save(): void
$settings = null; $settings = null;
if ($this->isSaml()) { if ($this->isSaml()) {
$attributes = [];
foreach ($this->form->samlAttributes as $attr) {
if (!empty($attr['saml_name']) && !empty($attr['user_field'])) {
$attributes[$attr['saml_name']] = $attr['user_field'];
}
}
$settings = [ $settings = [
"saml" => [ "saml" => [
"entity_id" => $this->form->samlEntityId, "entity_id" => $this->form->samlEntityId,
"acs_url" => $this->form->samlAcsUrl, "acs_url" => $this->form->samlAcsUrl,
"nameid_format" => $this->form->samlNameIdFormat,
"nameid_source" => $this->form->samlNameIdSource,
"attributes" => $attributes,
], ],
]; ];
} }
@ -118,6 +130,16 @@ public function save(): void
}, },
); );
} }
public function addSamlAttribute(): void
{
$this->form->addSamlAttribute();
}
public function removeSamlAttribute(int $index): void
{
$this->form->removeSamlAttribute($index);
}
}; };
?> ?>
@ -153,11 +175,7 @@ public function save(): void
/> />
@if($this->isSaml) @if($this->isSaml)
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-gray-700/50 pt-4 mt-2"> <x-dashboard.connected-apps.saml-configuration :form="$this->form"/>
<h3 class="font-semibold text-sm text-sky-400 md:col-span-2">SAML Configuration</h3>
<x-mary-input wire:model="form.samlEntityId" required :label="__('SAML Entity ID (Audience)')" placeholder="e.g. urn:federation:MicrosoftOnline" />
<x-mary-input wire:model="form.samlAcsUrl" required :label="__('SAML ACS URL (Callback)')" placeholder="e.g. https://login.microsoftonline.com/login.srf" />
</div>
@endif @endif
</div> </div>
<x-slot:actions class="pr-2"> <x-slot:actions class="pr-2">

View File

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
test('returns a successful response', function (): void {
$response = $this->get(route('home'));
$response->assertOk();
});

View File

@ -39,11 +39,17 @@
], ],
]); ]);
// Create a mock User // 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([ $this->user = User::factory()->create([
'email' => 'testuser@company.com', 'email' => 'testuser@company.com',
'immutable_id' => 'user-123-immutable', 'immutable_id' => 'user-123-immutable',
]); ]);
$this->user->assignRole($this->samlRole);
}); });
test('saml metadata endpoint returns active public certificate and sso location', function (): void { test('saml metadata endpoint returns active public certificate and sso location', function (): void {
@ -130,9 +136,9 @@
test('saml settings field is only visible if selected roles contain a SAML app', function (): void { test('saml settings field is only visible if selected roles contain a SAML app', function (): void {
// Create a role with a SAML connected app // Create a role with a SAML connected app
$samlRole = App\Models\Role::findOrCreate('SAML User'); $samlRole = App\Models\Role::findOrCreate('SAML Settings User');
$samlRole->update(['priority' => 10]); $samlRole->update(['priority' => 10]);
$samlRole->apps()->attach($this->samlApp->id, ['duration' => 365]); $samlRole->apps()->attach($this->samlApp->id, ['duration' => now()->addYear()->toDateTimeString()]);
// Create a role with no connected apps // Create a role with no connected apps
$emptyRole = App\Models\Role::findOrCreate('Empty User'); $emptyRole = App\Models\Role::findOrCreate('Empty User');
@ -157,3 +163,94 @@
->set('form.roleIds', [$samlRole->id]) ->set('form.roleIds', [$samlRole->id])
->assertSet('showSamlSettings', true); ->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"');
});

View File

@ -1,7 +0,0 @@
<?php
declare(strict_types=1);
test('that true is true', function (): void {
expect(true)->toBeTrue();
});

View File

@ -4,19 +4,23 @@
namespace Tests\Unit; namespace Tests\Unit;
uses(\Tests\TestCase::class); use App\Data\Ui\Dashboard\ServiceAppDto;
use App\Http\Controllers\SamlIdpController; use App\Http\Controllers\SamlIdpController;
use App\Models\{ConnectedApp, ConnectionStatus, User}; use App\Models\{ConnectedApp, ConnectionStatus, User};
use App\Services\SamlIdpService; use App\Services\{SamlIdpService, UserAppAccessService};
use Exception; use Exception;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Mockery; use Mockery;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function (): void { beforeEach(function (): void {
$this->mockService = Mockery::mock(SamlIdpService::class); $this->mockService = Mockery::mock(SamlIdpService::class);
$this->controller = new SamlIdpController($this->mockService); $this->mockAccessService = Mockery::mock(UserAppAccessService::class);
$this->controller = new SamlIdpController($this->mockService, $this->mockAccessService);
}); });
afterEach(function (): void { afterEach(function (): void {
@ -27,7 +31,7 @@
$request = Request::create('/saml/sso', 'GET'); $request = Request::create('/saml/sso', 'GET');
expect(fn () => $this->controller->sso($request)) expect(fn () => $this->controller->sso($request))
->toThrow(\Symfony\Component\HttpKernel\Exception\HttpException::class, 'Missing SAMLRequest parameter.'); ->toThrow(HttpException::class, 'Missing SAMLRequest parameter.');
}); });
test('sso throws 400 bad request when SAMLRequest fails to parse', function (): void { test('sso throws 400 bad request when SAMLRequest fails to parse', function (): void {
@ -39,7 +43,7 @@
->andThrow(new Exception('SAML XML parsing error.')); ->andThrow(new Exception('SAML XML parsing error.'));
expect(fn () => $this->controller->sso($request)) expect(fn () => $this->controller->sso($request))
->toThrow(\Symfony\Component\HttpKernel\Exception\HttpException::class, 'Invalid SAMLRequest: SAML XML parsing error.'); ->toThrow(HttpException::class, 'Invalid SAMLRequest: SAML XML parsing error.');
}); });
test('sso throws 403 forbidden when Service Provider is unregistered', function (): void { test('sso throws 403 forbidden when Service Provider is unregistered', function (): void {
@ -60,7 +64,7 @@
->andReturn(null); ->andReturn(null);
expect(fn () => $this->controller->sso($request)) expect(fn () => $this->controller->sso($request))
->toThrow(\Symfony\Component\HttpKernel\Exception\HttpException::class, 'The Service Provider [urn:unregistered] is not authorized in this system.'); ->toThrow(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 { test('sso throws 403 forbidden when Service Provider is registered but disconnected', function (): void {
@ -88,7 +92,7 @@
->andReturn($app); ->andReturn($app);
expect(fn () => $this->controller->sso($request)) expect(fn () => $this->controller->sso($request))
->toThrow(\Symfony\Component\HttpKernel\Exception\HttpException::class, 'The Service Provider [Test Disconnected App] is currently deactivated.'); ->toThrow(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 { test('sso redirects guest user to login page and stores SAML parameters in session', function (): void {
@ -139,6 +143,7 @@
$status->name = 'connected'; $status->name = 'connected';
$app = new ConnectedApp(); $app = new ConnectedApp();
$app->id = 1;
$app->name = 'Active SAML App'; $app->name = 'Active SAML App';
$app->setRelation('status', $status); $app->setRelation('status', $status);
$app->settings = ['saml' => ['acs_url' => 'https://acs.url']]; $app->settings = ['saml' => ['acs_url' => 'https://acs.url']];
@ -164,6 +169,20 @@
Auth::shouldReceive('check')->once()->andReturn(true); Auth::shouldReceive('check')->once()->andReturn(true);
Auth::shouldReceive('user')->once()->andReturn($user); Auth::shouldReceive('user')->once()->andReturn($user);
$this->mockAccessService->shouldReceive('getActiveServices')
->once()
->with($user)
->andReturn(collect([
new ServiceAppDto(
id: 1,
name: 'Active SAML App',
slug: 'active-saml-app',
duration: '2026-12-31',
days_remaining: 300,
is_warning: false
),
]));
$this->mockService->shouldReceive('generateResponse') $this->mockService->shouldReceive('generateResponse')
->once() ->once()
->with($user, $app, '_req_1', 'https://acs.url') ->with($user, $app, '_req_1', 'https://acs.url')
@ -198,3 +217,80 @@
->and($response->headers->get('Content-Type'))->toBe('application/xml; charset=utf-8') ->and($response->headers->get('Content-Type'))->toBe('application/xml; charset=utf-8')
->and($response->getContent())->toBe('<EntityDescriptor></EntityDescriptor>'); ->and($response->getContent())->toBe('<EntityDescriptor></EntityDescriptor>');
}); });
test('sso throws 400 bad request when request ACS URL does not match configured ACS URL', function (): void {
$request = Request::create('/saml/sso', 'GET', [
'SAMLRequest' => 'valid-request',
]);
$status = new ConnectionStatus();
$status->name = 'connected';
$app = new ConnectedApp();
$app->id = 1;
$app->name = 'Active SAML App';
$app->setRelation('status', $status);
$app->settings = ['saml' => ['acs_url' => 'https://configured.url']];
$this->mockService->shouldReceive('parseRequest')
->once()
->with('valid-request')
->andReturn([
'requestId' => '_req_1',
'issuer' => 'urn:active',
'acsUrl' => 'https://mismatching.url',
]);
$this->mockService->shouldReceive('findConnectedApp')
->once()
->with('urn:active')
->andReturn($app);
expect(fn () => $this->controller->sso($request))
->toThrow(HttpException::class, 'The Assertion Consumer Service (ACS) URL does not match the configured callback URL.');
});
test('sso throws 403 forbidden when authenticated user is not authorized for the app', function (): void {
$request = Request::create('/saml/sso', 'GET', [
'SAMLRequest' => 'valid-request',
]);
$status = new ConnectionStatus();
$status->name = 'connected';
$app = new ConnectedApp();
$app->id = 1;
$app->name = 'Active SAML App';
$app->setRelation('status', $status);
$app->settings = ['saml' => ['acs_url' => 'https://acs.url']];
$user = new User();
$user->email = 'unauthorized@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);
// Mock UserAppAccessService to return empty services
$this->mockAccessService->shouldReceive('getActiveServices')
->once()
->with($user)
->andReturn(collect());
expect(fn () => $this->controller->sso($request))
->toThrow(HttpException::class, 'You are not authorized to access this application.');
});