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;
|
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,
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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 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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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([
|
$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"');
|
||||||
|
});
|
||||||
|
|||||||
@ -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;
|
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.');
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user