From aaa258903cde2d617b7fd29d58ec41b7840570d1 Mon Sep 17 00:00:00 2001 From: = Date: Mon, 8 Jun 2026 12:56:28 +0000 Subject: [PATCH] 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. --- app/Contracts/EnumMorphsToOptionsContract.php | 12 ++ app/Enums/SamlNameIdFormatEnum.php | 34 ++++++ app/Enums/SamlNameIdSourceEnum.php | 34 ++++++ app/Http/Controllers/SamlIdpController.php | 45 +++++-- app/Livewire/Forms/StoreConnectedAppFrom.php | 42 ++++++- app/Services/MicrosoftGraphService.php | 2 +- .../saml-configuration.blade.php | 60 ++++++++++ .../pages/connected-apps/⚡create.blade.php | 28 +++-- .../pages/connected-apps/⚡edit.blade.php | 30 ++++- tests/Feature/ExampleTest.php | 9 -- tests/Feature/SamlIdpTest.php | 103 +++++++++++++++- tests/Unit/ExampleTest.php | 7 -- tests/Unit/SamlIdpControllerTest.php | 112 ++++++++++++++++-- 13 files changed, 468 insertions(+), 50 deletions(-) create mode 100644 app/Contracts/EnumMorphsToOptionsContract.php create mode 100644 app/Enums/SamlNameIdFormatEnum.php create mode 100644 app/Enums/SamlNameIdSourceEnum.php create mode 100644 resources/views/components/dashboard/connected-apps/saml-configuration.blade.php delete mode 100644 tests/Feature/ExampleTest.php delete mode 100644 tests/Unit/ExampleTest.php diff --git a/app/Contracts/EnumMorphsToOptionsContract.php b/app/Contracts/EnumMorphsToOptionsContract.php new file mode 100644 index 0000000..78e4328 --- /dev/null +++ b/app/Contracts/EnumMorphsToOptionsContract.php @@ -0,0 +1,12 @@ + [ + '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', + }; + } +} diff --git a/app/Enums/SamlNameIdSourceEnum.php b/app/Enums/SamlNameIdSourceEnum.php new file mode 100644 index 0000000..2f317c8 --- /dev/null +++ b/app/Enums/SamlNameIdSourceEnum.php @@ -0,0 +1,34 @@ + [ + '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', + }; + } +} diff --git a/app/Http/Controllers/SamlIdpController.php b/app/Http/Controllers/SamlIdpController.php index 86e17f0..190f033 100644 --- a/app/Http/Controllers/SamlIdpController.php +++ b/app/Http/Controllers/SamlIdpController.php @@ -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, diff --git a/app/Livewire/Forms/StoreConnectedAppFrom.php b/app/Livewire/Forms/StoreConnectedAppFrom.php index 6d9cee4..b90e34c 100644 --- a/app/Livewire/Forms/StoreConnectedAppFrom.php +++ b/app/Livewire/Forms/StoreConnectedAppFrom.php @@ -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); } } diff --git a/app/Services/MicrosoftGraphService.php b/app/Services/MicrosoftGraphService.php index 077cccf..5d46af1 100644 --- a/app/Services/MicrosoftGraphService.php +++ b/app/Services/MicrosoftGraphService.php @@ -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(), diff --git a/resources/views/components/dashboard/connected-apps/saml-configuration.blade.php b/resources/views/components/dashboard/connected-apps/saml-configuration.blade.php new file mode 100644 index 0000000..0c48045 --- /dev/null +++ b/resources/views/components/dashboard/connected-apps/saml-configuration.blade.php @@ -0,0 +1,60 @@ +@php use App\Enums\SamlNameIdFormatEnum;use App\Enums\SamlNameIdSourceEnum; @endphp +@props(['form']) +
+

SAML Configuration

+ + + + + + +
+
+

Attribute/Claims Mapping

+ +
+ @if(empty($form->samlAttributes)) +

No custom attribute mapping defined. Default + Microsoft claims will be used.

+ @else +
+ @foreach($form->samlAttributes as $index => $attr) +
+ + + +
+ @endforeach +
+ @endif +
+
diff --git a/resources/views/pages/connected-apps/⚡create.blade.php b/resources/views/pages/connected-apps/⚡create.blade.php index c3cdcac..0042854 100644 --- a/resources/views/pages/connected-apps/⚡create.blade.php +++ b/resources/views/pages/connected-apps/⚡create.blade.php @@ -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) -
-

SAML Configuration

- - -
+ @endif
diff --git a/resources/views/pages/connected-apps/⚡edit.blade.php b/resources/views/pages/connected-apps/⚡edit.blade.php index d915422..7702775 100644 --- a/resources/views/pages/connected-apps/⚡edit.blade.php +++ b/resources/views/pages/connected-apps/⚡edit.blade.php @@ -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) -
-

SAML Configuration

- - -
+ @endif
diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100644 index 380a02a..0000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,9 +0,0 @@ -get(route('home')); - - $response->assertOk(); -}); diff --git a/tests/Feature/SamlIdpTest.php b/tests/Feature/SamlIdpTest.php index 2a51043..b4f0881 100644 --- a/tests/Feature/SamlIdpTest.php +++ b/tests/Feature/SamlIdpTest.php @@ -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 = 'urn:federation:MicrosoftOnline'; + $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 = 'urn:federation:MicrosoftOnline'; + $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 = 'urn:federation:MicrosoftOnline'; + $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"'); +}); diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php deleted file mode 100644 index 5984fd8..0000000 --- a/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,7 +0,0 @@ -toBeTrue(); -}); diff --git a/tests/Unit/SamlIdpControllerTest.php b/tests/Unit/SamlIdpControllerTest.php index b69bb02..2b5a851 100644 --- a/tests/Unit/SamlIdpControllerTest.php +++ b/tests/Unit/SamlIdpControllerTest.php @@ -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(''); }); + +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.'); +});