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.');
+});