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:
parent
215d3cad7f
commit
aaa258903c
12
app/Contracts/EnumMorphsToOptionsContract.php
Normal file
12
app/Contracts/EnumMorphsToOptionsContract.php
Normal file
@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Contracts;
|
||||
|
||||
interface EnumMorphsToOptionsContract
|
||||
{
|
||||
public static function asOptions(): array;
|
||||
|
||||
public function toLabel(): string;
|
||||
}
|
||||
34
app/Enums/SamlNameIdFormatEnum.php
Normal file
34
app/Enums/SamlNameIdFormatEnum.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
34
app/Enums/SamlNameIdSourceEnum.php
Normal file
34
app/Enums/SamlNameIdSourceEnum.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -5,14 +5,17 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\SamlIdpService;
|
||||
use App\Services\{SamlIdpService, UserAppAccessService};
|
||||
use Illuminate\Http\{Request, Response};
|
||||
use Illuminate\Support\Facades\{Auth, Log};
|
||||
use Throwable;
|
||||
|
||||
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.
|
||||
@ -95,11 +98,11 @@ public function sso(Request $request)
|
||||
* }
|
||||
* } $settings
|
||||
*/
|
||||
$settings = $connectedApp->settings;
|
||||
$acsUrl = $parsed['acsUrl'] ?: $settings['saml']['acs_url'] ?? null;
|
||||
$settings = (array) $connectedApp->settings;
|
||||
$configuredAcsUrl = $settings['saml']['acs_url'] ?? null;
|
||||
|
||||
if (empty($acsUrl)) {
|
||||
Log::error('SAML SSO failed: Assertion Consumer Service (ACS) URL is undefined.', [
|
||||
if (empty($configuredAcsUrl)) {
|
||||
Log::error('SAML SSO failed: Assertion Consumer Service (ACS) URL is undefined in Connected App settings.', [
|
||||
'app_id' => $connectedApp->id,
|
||||
'app_name' => $connectedApp->name,
|
||||
'request_id' => $parsed['requestId'],
|
||||
@ -107,6 +110,23 @@ public function sso(Request $request)
|
||||
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
|
||||
if (! Auth::check()) {
|
||||
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 */
|
||||
$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_email' => $user->email,
|
||||
'app_id' => $connectedApp->id,
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
namespace App\Livewire\Forms;
|
||||
|
||||
use App\Data\ConnectedApp\ConnectedAppData;
|
||||
use App\Enums\ConnectionProtocolEnum;
|
||||
use App\Models\ConnectionProtocol;
|
||||
use App\Services\ConnectedAppService;
|
||||
use Livewire\Form;
|
||||
|
||||
@ -22,6 +24,12 @@ class StoreConnectedAppFrom extends Form
|
||||
|
||||
public string $samlAcsUrl = '';
|
||||
|
||||
public string $samlNameIdFormat = 'persistent';
|
||||
|
||||
public string $samlNameIdSource = 'immutable_id';
|
||||
|
||||
public array $samlAttributes = [];
|
||||
|
||||
protected ConnectedAppService $service;
|
||||
|
||||
public function boot(ConnectedAppService $service): void
|
||||
@ -43,10 +51,15 @@ public function rules(): array
|
||||
'statusId' => 'required|numeric|min:1|exists:connection_statuses,id',
|
||||
];
|
||||
|
||||
$protocol = \App\Models\ConnectionProtocol::find($this->protocolId);
|
||||
if ($protocol && 'saml' === mb_strtolower($protocol->name)) {
|
||||
$protocol = ConnectionProtocol::query()->find($this->protocolId);
|
||||
if ($protocol && ConnectionProtocolEnum::SAML->value === mb_strtolower($protocol->name)) {
|
||||
$rules['samlEntityId'] = 'required|string|min:3';
|
||||
$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;
|
||||
@ -64,5 +77,30 @@ public function set(ConnectedAppData $data): void
|
||||
$this->statusId = $data->statusId;
|
||||
$this->samlEntityId = $data->settings['saml']['entity_id'] ?? '';
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -420,7 +420,7 @@ public function connectFederation(
|
||||
$errorMsg = $errorJson['message'] ?? 'Failed to create federation configuration.';
|
||||
|
||||
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.', [
|
||||
'status_code' => $response->status(),
|
||||
|
||||
@ -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>
|
||||
@ -18,7 +18,8 @@
|
||||
use Mary\Traits\Toast;
|
||||
use Spatie\LaravelData\DataCollection;
|
||||
|
||||
new #[Title("Create a service")] class extends Component {
|
||||
new #[Title("Create a service")]
|
||||
class extends Component {
|
||||
use HandlesOperations;
|
||||
|
||||
public StoreConnectedAppFrom $form;
|
||||
@ -82,10 +83,19 @@ public function save(bool $goBack = true): void
|
||||
|
||||
$settings = null;
|
||||
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 = [
|
||||
"saml" => [
|
||||
"entity_id" => $this->form->samlEntityId,
|
||||
"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
|
||||
{
|
||||
@ -148,11 +166,7 @@ public function goBack(): void
|
||||
/>
|
||||
|
||||
@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">
|
||||
<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>
|
||||
<x-dashboard.connected-apps.saml-configuration :form="$this->form"/>
|
||||
@endif
|
||||
|
||||
<div class="md:col-span-2 flex gap-x-4 font-medium justify-end">
|
||||
|
||||
@ -19,10 +19,13 @@
|
||||
use Livewire\Attributes\Title;
|
||||
use Mary\Traits\Toast;
|
||||
use Spatie\LaravelData\DataCollection;
|
||||
|
||||
/**
|
||||
* @property $protocols
|
||||
*/
|
||||
new #[Lazy] #[Title("Edit a connected app")] class extends Component {
|
||||
new #[Lazy]
|
||||
#[Title("Edit a connected app")]
|
||||
class extends Component {
|
||||
use HandlesOperations;
|
||||
|
||||
public StoreConnectedAppFrom $form;
|
||||
@ -97,10 +100,19 @@ public function save(): void
|
||||
|
||||
$settings = null;
|
||||
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 = [
|
||||
"saml" => [
|
||||
"entity_id" => $this->form->samlEntityId,
|
||||
"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)
|
||||
<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">
|
||||
<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>
|
||||
<x-dashboard.connected-apps.saml-configuration :form="$this->form"/>
|
||||
@endif
|
||||
</div>
|
||||
<x-slot:actions class="pr-2">
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
test('returns a successful response', function (): void {
|
||||
$response = $this->get(route('home'));
|
||||
|
||||
$response->assertOk();
|
||||
});
|
||||
@ -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([
|
||||
'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 {
|
||||
@ -130,9 +136,9 @@
|
||||
|
||||
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 User');
|
||||
$samlRole = App\Models\Role::findOrCreate('SAML Settings User');
|
||||
$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
|
||||
$emptyRole = App\Models\Role::findOrCreate('Empty User');
|
||||
@ -157,3 +163,94 @@
|
||||
->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"');
|
||||
});
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
test('that true is true', function (): void {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
@ -4,19 +4,23 @@
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
uses(\Tests\TestCase::class);
|
||||
|
||||
use App\Data\Ui\Dashboard\ServiceAppDto;
|
||||
use App\Http\Controllers\SamlIdpController;
|
||||
use App\Models\{ConnectedApp, ConnectionStatus, User};
|
||||
use App\Services\SamlIdpService;
|
||||
use App\Services\{SamlIdpService, UserAppAccessService};
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Mockery;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$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 {
|
||||
@ -27,7 +31,7 @@
|
||||
$request = Request::create('/saml/sso', 'GET');
|
||||
|
||||
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 {
|
||||
@ -39,7 +43,7 @@
|
||||
->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.');
|
||||
->toThrow(HttpException::class, 'Invalid SAMLRequest: SAML XML parsing error.');
|
||||
});
|
||||
|
||||
test('sso throws 403 forbidden when Service Provider is unregistered', function (): void {
|
||||
@ -60,7 +64,7 @@
|
||||
->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.');
|
||||
->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 {
|
||||
@ -88,7 +92,7 @@
|
||||
->andReturn($app);
|
||||
|
||||
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 {
|
||||
@ -139,6 +143,7 @@
|
||||
$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']];
|
||||
@ -164,6 +169,20 @@
|
||||
Auth::shouldReceive('check')->once()->andReturn(true);
|
||||
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')
|
||||
->once()
|
||||
->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->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.');
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user