From df4a84b97039dfc6a1f659938fbf8c7e41fee7ac Mon Sep 17 00:00:00 2001 From: = Date: Thu, 28 May 2026 12:43:17 +0000 Subject: [PATCH] Feat: introduce Microsoft Entra ID federation configuration - Added `MicrosoftGraphCredentialsData` and `FederatedIdpMfaBehaviorEnum` for managing Entra ID integration. - Implemented `MicrosoftGraphService` to handle federation setup, domain management, and token authentication. - Created UI for managing Microsoft Graph Federation, including connection and disconnection flows. - Extended tests to validate Microsoft Graph Federation functionality. --- .env.example | 9 + .../MicrosoftGraphCredentialsData.php | 23 + app/Enums/FederatedIdpMfaBehaviorEnum.php | 19 + app/Services/MicrosoftGraphService.php | 364 +++++++++++ boost.json | 7 +- config/services.php | 9 + .../components/dashboard-sidebar.blade.php | 6 + .../⚡microsoft-federation.blade.php | 587 ++++++++++++++++++ routes/web.php | 4 + .../Feature/MicrosoftGraphFederationTest.php | 154 +++++ 10 files changed, 1176 insertions(+), 6 deletions(-) create mode 100644 app/Data/MicrosoftGraph/MicrosoftGraphCredentialsData.php create mode 100644 app/Enums/FederatedIdpMfaBehaviorEnum.php create mode 100644 app/Services/MicrosoftGraphService.php create mode 100644 resources/views/pages/connecton-providers/⚡microsoft-federation.blade.php create mode 100644 tests/Feature/MicrosoftGraphFederationTest.php diff --git a/.env.example b/.env.example index c0660ea..0f986fe 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,12 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +# Microsoft Graph API credentials for Domain Federation +MICROSOFT_GRAPH_TENANT_ID= +MICROSOFT_GRAPH_CLIENT_ID= +MICROSOFT_GRAPH_CLIENT_SECRET= +MICROSOFT_GRAPH_DOMAIN_NAME= +MICROSOFT_GRAPH_DISPLAY_NAME="Company SSO" +MICROSOFT_GRAPH_MFA_BEHAVIOR="acceptIfMfaDoneByFederatedIdp" + diff --git a/app/Data/MicrosoftGraph/MicrosoftGraphCredentialsData.php b/app/Data/MicrosoftGraph/MicrosoftGraphCredentialsData.php new file mode 100644 index 0000000..8983f27 --- /dev/null +++ b/app/Data/MicrosoftGraph/MicrosoftGraphCredentialsData.php @@ -0,0 +1,23 @@ +tenantId}/oauth2/v2.0/token"; + + $body = [ + 'client_id' => $credentials->clientId, + 'client_secret' => $credentials->clientSecret, + 'scope' => 'https://graph.microsoft.com/.default', + 'grant_type' => 'client_credentials', + ]; + + $requestHeaders = [ + 'Content-Type' => 'application/x-www-form-urlencoded', + ]; + + try { + $response = Http::asForm()->post($url, $body); + + $transaction = [ + 'name' => 'OAuth 2.0 Get Access Token', + 'url' => $url, + 'method' => 'POST', + 'request_headers' => $requestHeaders, + 'request_body' => collect($body)->except('client_secret')->put('client_secret', '********')->toArray(), + 'status' => $response->status(), + 'response_headers' => $response->headers(), + 'response_body' => $response->json(), + ]; + + if ($response->successful()) { + return [ + 'token' => $response->json('access_token'), + 'transaction' => $transaction, + ]; + } + } catch (Exception $e) { + $transaction = [ + 'name' => 'OAuth 2.0 Get Access Token', + 'url' => $url, + 'method' => 'POST', + 'request_headers' => $requestHeaders, + 'request_body' => collect($body)->except('client_secret')->put('client_secret', '********')->toArray(), + 'status' => 'ERROR', + 'response_body' => ['error' => $e->getMessage()], + ]; + } + + return [ + 'token' => null, + 'transaction' => $transaction, + ]; + } + + /** + * Retrieve federation configuration for the domain. + * + * @return array{status: string, message?: string, config: ?array, transactions: array} + */ + public function getFederationConfig(MicrosoftGraphCredentialsData $credentials): array + { + $auth = $this->getAccessToken($credentials); + $transactions = [$auth['transaction']]; + + if (! $auth['token']) { + return [ + 'status' => 'error', + 'message' => 'Authentication with Microsoft Graph failed.', + 'config' => null, + 'transactions' => $transactions, + ]; + } + + $url = "https://graph.microsoft.com/v1.0/domains/{$credentials->domainName}/federationConfiguration"; + $headers = [ + 'Authorization' => 'Bearer ********', + 'Content-Type' => 'application/json', + ]; + + try { + $response = Http::withToken($auth['token'])->get($url); + + $transactions[] = [ + 'name' => 'Get Federation Configuration', + 'url' => $url, + 'method' => 'GET', + 'request_headers' => $headers, + 'request_body' => null, + 'status' => $response->status(), + 'response_headers' => $response->headers(), + 'response_body' => $response->json(), + ]; + + if ($response->successful()) { + /** @var array $value */ + $value = $response->json('value', []); + + return [ + 'status' => 'success', + 'config' => ! empty($value) ? $value[0] : null, + 'transactions' => $transactions, + ]; + } + + /** @var string $errorMsg */ + $errorMsg = $response->json('error.message', 'Failed to retrieve federation config.'); + + return [ + 'status' => 'error', + 'message' => $errorMsg, + 'config' => null, + 'transactions' => $transactions, + ]; + } catch (Exception $e) { + $transactions[] = [ + 'name' => 'Get Federation Configuration', + 'url' => $url, + 'method' => 'GET', + 'request_headers' => $headers, + 'request_body' => null, + 'status' => 'ERROR', + 'response_body' => ['error' => $e->getMessage()], + ]; + + return [ + 'status' => 'error', + 'message' => $e->getMessage(), + 'config' => null, + 'transactions' => $transactions, + ]; + } + } + + /** + * Connect/Federate the domain. + * + * @return array{status: string, message?: string, config?: ?array, transactions: array} + */ + public function connectFederation( + MicrosoftGraphCredentialsData $credentials, + string $certPem, + string $issuerUri, + string $passiveSignInUri, + string $activeSignInUri, + string $signOutUri + ): array { + $auth = $this->getAccessToken($credentials); + $transactions = [$auth['transaction']]; + + if (! $auth['token']) { + return [ + 'status' => 'error', + 'message' => 'Authentication with Microsoft Graph failed.', + 'transactions' => $transactions, + ]; + } + + // Check and delete existing configuration to ensure a clean setup + $checkUrl = "https://graph.microsoft.com/v1.0/domains/{$credentials->domainName}/federationConfiguration"; + try { + $checkRes = Http::withToken($auth['token'])->get($checkUrl); + $transactions[] = [ + 'name' => 'Check Existing Federation Configuration', + 'url' => $checkUrl, + 'method' => 'GET', + 'status' => $checkRes->status(), + 'response_body' => $checkRes->json(), + ]; + + if ($checkRes->successful()) { + /** @var array $configs */ + $configs = $checkRes->json('value', []); + if (! empty($configs)) { + /** @var string $existingId */ + $existingId = $configs[0]['id']; + $deleteUrl = "https://graph.microsoft.com/v1.0/domains/{$credentials->domainName}/federationConfiguration/{$existingId}"; + $deleteRes = Http::withToken($auth['token'])->delete($deleteUrl); + $transactions[] = [ + 'name' => 'Delete Pre-existing Federation Configuration', + 'url' => $deleteUrl, + 'method' => 'DELETE', + 'status' => $deleteRes->status(), + 'response_body' => $deleteRes->json() ?: ['message' => 'Deleted successfully'], + ]; + + if (! $deleteRes->successful()) { + return [ + 'status' => 'error', + 'message' => 'Failed to delete pre-existing federation configuration.', + 'transactions' => $transactions, + ]; + } + } + } + } catch (Exception $e) { + // Suppress exception and proceed + } + + // Prepare the signing certificate PEM string + $signingCertificateClean = str_replace( + ['-----BEGIN CERTIFICATE-----', '-----END CERTIFICATE-----', "\r", "\n", ' '], + '', + $certPem + ); + + $url = "https://graph.microsoft.com/v1.0/domains/{$credentials->domainName}/federationConfiguration"; + $body = [ + '@odata.type' => '#microsoft.graph.internalDomainFederation', + 'displayName' => $credentials->displayName, + 'issuerUri' => $issuerUri, + 'signingCertificate' => $signingCertificateClean, + 'passiveSignInUri' => $passiveSignInUri, + 'preferredAuthenticationProtocol' => 'saml', + 'activeSignInUri' => $activeSignInUri, + 'signOutUri' => $signOutUri, + 'promptLoginBehavior' => 'nativeSupport', + 'isSignedAuthenticationRequestRequired' => false, + 'federatedIdpMfaBehavior' => $credentials->mfaBehavior->value, + ]; + + $headers = [ + 'Authorization' => 'Bearer ********', + 'Content-Type' => 'application/json', + ]; + + try { + $response = Http::withToken($auth['token'])->post($url, $body); + + $transactions[] = [ + 'name' => 'Create Federation Configuration', + 'url' => $url, + 'method' => 'POST', + 'request_headers' => $headers, + 'request_body' => $body, + 'status' => $response->status(), + 'response_headers' => $response->headers(), + 'response_body' => $response->json(), + ]; + + if ($response->successful()) { + return [ + 'status' => 'success', + 'config' => $response->json(), + 'transactions' => $transactions, + ]; + } + + /** @var string $errorMsg */ + $errorMsg = $response->json('error.message', 'Failed to create federation configuration.'); + + return [ + 'status' => 'error', + 'message' => $errorMsg, + 'transactions' => $transactions, + ]; + } catch (Exception $e) { + $transactions[] = [ + 'name' => 'Create Federation Configuration', + 'url' => $url, + 'method' => 'POST', + 'request_headers' => $headers, + 'request_body' => $body, + 'status' => 'ERROR', + 'response_body' => ['error' => $e->getMessage()], + ]; + + return [ + 'status' => 'error', + 'message' => $e->getMessage(), + 'transactions' => $transactions, + ]; + } + } + + /** + * Disconnect/Remove federation configuration. + * + * @return array{status: string, message?: string, transactions: array} + */ + public function disconnectFederation(MicrosoftGraphCredentialsData $credentials, string $federationId): array + { + $auth = $this->getAccessToken($credentials); + $transactions = [$auth['transaction']]; + + if (! $auth['token']) { + return [ + 'status' => 'error', + 'message' => 'Authentication with Microsoft Graph failed.', + 'transactions' => $transactions, + ]; + } + + $url = "https://graph.microsoft.com/v1.0/domains/{$credentials->domainName}/federationConfiguration/{$federationId}"; + $headers = [ + 'Authorization' => 'Bearer ********', + ]; + + try { + $response = Http::withToken($auth['token'])->delete($url); + + $transactions[] = [ + 'name' => 'Delete Federation Configuration', + 'url' => $url, + 'method' => 'DELETE', + 'request_headers' => $headers, + 'request_body' => null, + 'status' => $response->status(), + 'response_headers' => $response->headers(), + 'response_body' => $response->json() ?: ['message' => 'Federation removed successfully. Domain returned to Managed.'], + ]; + + if ($response->successful()) { + return [ + 'status' => 'success', + 'transactions' => $transactions, + ]; + } + + /** @var string $errorMsg */ + $errorMsg = $response->json('error.message', 'Failed to remove federation configuration.'); + + return [ + 'status' => 'error', + 'message' => $errorMsg, + 'transactions' => $transactions, + ]; + } catch (Exception $e) { + $transactions[] = [ + 'name' => 'Delete Federation Configuration', + 'url' => $url, + 'method' => 'DELETE', + 'request_headers' => $headers, + 'request_body' => null, + 'status' => 'ERROR', + 'response_body' => ['error' => $e->getMessage()], + ]; + + return [ + 'status' => 'error', + 'message' => $e->getMessage(), + 'transactions' => $transactions, + ]; + } + } +} diff --git a/boost.json b/boost.json index 1129526..b327db2 100644 --- a/boost.json +++ b/boost.json @@ -1,9 +1,5 @@ { - "agents": [ - "claude_code", - "copilot", - "opencode" - ], + "agents": ["claude_code", "copilot", "opencode"], "cloud": false, "guidelines": true, "mcp": true, @@ -12,7 +8,6 @@ "skills": [ "fortify-development", "laravel-best-practices", - "fluxui-development", "livewire-development", "pest-testing", "tailwindcss-development" diff --git a/config/services.php b/config/services.php index 9adca09..ba69a61 100644 --- a/config/services.php +++ b/config/services.php @@ -37,4 +37,13 @@ ], ], + 'microsoft_graph' => [ + 'tenant_id' => env('MICROSOFT_GRAPH_TENANT_ID'), + 'client_id' => env('MICROSOFT_GRAPH_CLIENT_ID'), + 'client_secret' => env('MICROSOFT_GRAPH_CLIENT_SECRET'), + 'domain_name' => env('MICROSOFT_GRAPH_DOMAIN_NAME'), + 'display_name' => env('MICROSOFT_GRAPH_DISPLAY_NAME', 'Company SSO'), + 'mfa_behavior' => env('MICROSOFT_GRAPH_MFA_BEHAVIOR', 'acceptIfMfaDoneByFederatedIdp'), + ], + ]; diff --git a/resources/views/components/dashboard-sidebar.blade.php b/resources/views/components/dashboard-sidebar.blade.php index 8d63fcb..4bbdd26 100644 --- a/resources/views/components/dashboard-sidebar.blade.php +++ b/resources/views/components/dashboard-sidebar.blade.php @@ -56,6 +56,12 @@ public function navItems(): DataCollection route: 'apps.connectionProviders.index', active_pattern: 'apps.connectionProviders.*', permission: ConnectionProviderPermissionEnum::Access + ), + new NavSubItemData( + label: 'Microsoft Graph', + route: 'apps.microsoft-federation', + active_pattern: 'apps.microsoft-federation', + permission: ConnectionProviderPermissionEnum::Access ) ], DataCollection::class) ), diff --git a/resources/views/pages/connecton-providers/⚡microsoft-federation.blade.php b/resources/views/pages/connecton-providers/⚡microsoft-federation.blade.php new file mode 100644 index 0000000..08c993c --- /dev/null +++ b/resources/views/pages/connecton-providers/⚡microsoft-federation.blade.php @@ -0,0 +1,587 @@ +service = $service; + } + + #[Computed] + public function mfaBehaviorOptions(): array + { + return array_map(fn($case) => [ + 'id' => $case->value, + 'name' => match ($case) { + FederatedIdpMfaBehaviorEnum::AcceptIfMfaDoneByFederatedIdp => 'Accept if MFA Done by Federated IdP', + FederatedIdpMfaBehaviorEnum::EnforceMfaByFederatedIdp => 'Enforce MFA by Federated IdP', + FederatedIdpMfaBehaviorEnum::RejectMfaByFederatedIdp => 'Always perform MFA (Reject Federated IdP MFA)', + }, + ], FederatedIdpMfaBehaviorEnum::cases()); + } + + public function mount(): void + { + $config = config('services.microsoft_graph'); + + $this->tenantId = (string) ($config['tenant_id'] ?? ''); + $this->clientId = (string) ($config['client_id'] ?? ''); + $this->clientSecret = (string) ($config['client_secret'] ?? ''); + $this->domainName = (string) ($config['domain_name'] ?? ''); + $this->displayName = (string) ($config['display_name'] ?? 'Company SSO'); + $this->mfaBehavior = FederatedIdpMfaBehaviorEnum::tryFrom( + (string) ($config['mfa_behavior'] ?? '') + ) ?? FederatedIdpMfaBehaviorEnum::AcceptIfMfaDoneByFederatedIdp; + + if ( + !empty($this->tenantId) && + !empty($this->clientId) && + !empty($this->clientSecret) && + !empty($this->domainName) + ) { + $this->credentialsLoaded = true; + } + + if (file_exists(storage_path("app/saml/idp.crt"))) { + $this->certificateLoaded = true; + } + + if ($this->credentialsLoaded) { + $this->checkStatus(true); + } + } + + public function checkStatus(bool $silent = false): void + { + $this->attempt( + action: function () use ($silent) { + $credentials = new MicrosoftGraphCredentialsData( + tenantId: $this->tenantId, + clientId: $this->clientId, + clientSecret: $this->clientSecret, + domainName: $this->domainName, + displayName: $this->displayName, + mfaBehavior: $this->mfaBehavior, + ); + + $result = $this->service->getFederationConfig($credentials); + + foreach ($result["transactions"] as $tx) { + $this->debugResponses[] = array_merge($tx, [ + "timestamp" => now()->format("Y-m-d H:i:s"), + ]); + } + + if ($result["status"] === "success") { + if ($result["config"] !== null) { + $this->connectionStatus = + ConnectionStatusEnum::Connected; + $this->federationId = $result["config"]["id"]; + $this->activeFederationConfig = $result["config"]; + if (!$silent) { + $this->success( + "Federation is active on Microsoft Entra ID!", + ); + } + } else { + $this->connectionStatus = + ConnectionStatusEnum::Disconnected; + $this->federationId = null; + $this->activeFederationConfig = null; + if (!$silent) { + $this->success( + "Domain is managed. No federation configuration found.", + ); + } + } + } else { + $this->connectionStatus = ConnectionStatusEnum::Error; + if (!$silent) { + $this->error( + $result["message"] ?? "Failed to check status.", + ); + } + } + }, + showSuccess: false, + ); + } + + public function connect(): void + { + if (!$this->certificateLoaded) { + $this->error( + "Missing public certificate! Run php artisan saml:generate-keys first.", + ); + return; + } + + $this->attempt( + action: function () { + $credentials = new MicrosoftGraphCredentialsData( + tenantId: $this->tenantId, + clientId: $this->clientId, + clientSecret: $this->clientSecret, + domainName: $this->domainName, + displayName: $this->displayName, + mfaBehavior: $this->mfaBehavior, + ); + + $certPem = file_get_contents(storage_path("app/saml/idp.crt")); + $issuerUri = route("saml.metadata"); + $passiveSignInUri = route("saml.sso"); + $activeSignInUri = route("saml.sso"); + $signOutUri = route("home"); + + $result = $this->service->connectFederation( + credentials: $credentials, + certPem: $certPem, + issuerUri: $issuerUri, + passiveSignInUri: $passiveSignInUri, + activeSignInUri: $activeSignInUri, + signOutUri: $signOutUri, + ); + + foreach ($result["transactions"] as $tx) { + $this->debugResponses[] = array_merge($tx, [ + "timestamp" => now()->format("Y-m-d H:i:s"), + ]); + } + + if ($result["status"] === "success") { + $this->connectionStatus = ConnectionStatusEnum::Connected; + $this->federationId = $result["config"]["id"] ?? null; + $this->activeFederationConfig = $result["config"]; + $this->success( + "SAML IDP successfully federated with Microsoft Entra ID!", + ); + } else { + $this->connectionStatus = ConnectionStatusEnum::Error; + $this->error( + $result["message"] ?? "Failed to connect federation.", + ); + } + }, + showSuccess: false, + ); + } + + public function disconnect(): void + { + if (empty($this->federationId)) { + $this->error("No active federation ID found. Check status first."); + return; + } + + $this->attempt( + action: function () { + $credentials = new MicrosoftGraphCredentialsData( + tenantId: $this->tenantId, + clientId: $this->clientId, + clientSecret: $this->clientSecret, + domainName: $this->domainName, + displayName: $this->displayName, + mfaBehavior: $this->mfaBehavior, + ); + + $result = $this->service->disconnectFederation( + $credentials, + $this->federationId, + ); + + foreach ($result["transactions"] as $tx) { + $this->debugResponses[] = array_merge($tx, [ + "timestamp" => now()->format("Y-m-d H:i:s"), + ]); + } + + if ($result["status"] === "success") { + $this->connectionStatus = + ConnectionStatusEnum::Disconnected; + $this->federationId = null; + $this->activeFederationConfig = null; + $this->success( + "SAML IDP disconnected from Microsoft Entra ID. Domain returned to Managed.", + ); + } else { + $this->connectionStatus = ConnectionStatusEnum::Error; + $this->error( + $result["message"] ?? + "Failed to disconnect federation.", + ); + } + }, + showSuccess: false, + ); + } + + public function clearDebug(): void + { + $this->debugResponses = []; + $this->success("Debug outputs cleared."); + } +}; +?> + +
+
+ + @if(!$credentialsLoaded) +
+ +
+

Environment Configuration Required

+

+ To enable the Microsoft Graph API integration, please configure the environment, to include + needed values. +

+
+
+ @endif + + @if($credentialsLoaded && !$certificateLoaded) +
+ +
+

SAML Signing Certificate Missing

+

+ A verified public certificate is required to sign SAML requests sent to Microsoft Entra ID. No + certificate was found. +

+
+
+ @endif + + +
+ +
+ + +
+ Status: + @if($connectionStatus === ConnectionStatusEnum::Connected) + + + Federated + + @elseif($connectionStatus === ConnectionStatusEnum::Disconnected) + + + Managed + + @elseif($connectionStatus === ConnectionStatusEnum::Pending) + + + Connecting... + + @else + + + Error + + @endif +
+
+ +
+
+
+ + MICROSOFT_GRAPH_TENANT_ID +
+ +
+ + MICROSOFT_GRAPH_CLIENT_ID +
+ +
+ + MICROSOFT_GRAPH_CLIENT_SECRET +
+ +
+ + MICROSOFT_GRAPH_DOMAIN_NAME +
+ +
+ + MICROSOFT_GRAPH_DISPLAY_NAME +
+ +
+ + MICROSOFT_GRAPH_MFA_BEHAVIOR +
+
+ + +
+

+ + local Identity Provider Endpoints +

+
+
+ Issuer URI (Entity ID) + {{ route('saml.metadata') }} +
+
+ Passive Sign-In Endpoint (SSO) + {{ route('saml.sso') }} +
+
+ Active Sign-In Endpoint (WS-Trust) + {{ route('saml.sso') }} +
+
+ Sign-Out URI + {{ route('home') }} +
+
+
+ + +
+ + Check Status + + +
+ @if($connectionStatus === ConnectionStatusEnum::Connected) + + Disconnect Federation + + @else + + Connect & Federate + + @endif +
+
+
+
+
+ + +
+ +
+ @if($activeFederationConfig) +
+
+ Federation ID + {{ Str::limit($activeFederationConfig['id'], 18) }} +
+
+ Preferred Protocol + {{ $activeFederationConfig['preferredAuthenticationProtocol'] ?? 'Unknown' }} +
+
+ Entra MFA Rule + {{ $activeFederationConfig['federatedIdpMfaBehavior'] ?? 'Unknown' }} +
+
+ Active Signing Certificate Thumbprint +
+ {{ hash('sha1', base64_decode($activeFederationConfig['signingCertificate'])) }} +
+
+
+ @else +
+
+ +
+

No Active Federation

+

Connect this IDP + to Microsoft Entra to sync active configurations.

+
+ @endif +
+
+
+
+ + + + + @if(count($debugResponses) > 0) + + Clear Logs + + @endif + + +
+ @if(count($debugResponses) > 0) +
+ @foreach(array_reverse($debugResponses) as $index => $log) +
+ + + + +
+ + @if(!empty($log['request_headers']) || !empty($log['request_body'])) +
+ @if(!empty($log['request_headers'])) +
+ Request Headers +
{{ json_encode($log['request_headers'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}
+
+ @endif + @if(!empty($log['request_body'])) +
+ Request Payload +
{{ json_encode($log['request_body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}
+
+ @endif +
+ @endif + + +
+ Response Body Payload +
{{ json_encode($log['response_body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}
+
+
+
+ @endforeach +
+ @else +
+
+ +
+

Terminal Output Empty

+

Federation + transactions will appear here as HTTP raw records when you click Check, Connect, or + Disconnect.

+
+ @endif +
+
+
+
+ + diff --git a/routes/web.php b/routes/web.php index 40e8040..766ebb2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -57,6 +57,10 @@ }); Route::prefix('apps')->name('apps.')->group(function (): void { + Route::livewire('microsoft-federation', 'pages::connecton-providers.microsoft-federation') + ->middleware(PermissionMiddleware::using(ConnectionProviderPermissionEnum::Access)) + ->name('microsoft-federation'); + Route::middleware(PermissionMiddleware::using(AppPermissionEnum::Access))->group(function (): void { Route::livewire('create', 'pages::connected-apps.create')->name('create'); Route::livewire('{id}/edit', 'pages::connected-apps.edit')->name('edit'); diff --git a/tests/Feature/MicrosoftGraphFederationTest.php b/tests/Feature/MicrosoftGraphFederationTest.php new file mode 100644 index 0000000..1bdafb5 --- /dev/null +++ b/tests/Feature/MicrosoftGraphFederationTest.php @@ -0,0 +1,154 @@ +seed(Database\Seeders\DatabaseSeeder::class); + $this->admin = User::where('email', 'admin@example.com')->firstOrFail(); + + // Ensure mock SAML certificate directory and file exists for tests + $directory = storage_path('app/saml'); + if (! File::exists($directory)) { + File::makeDirectory($directory, 0755, true); + } + if (! File::exists("{$directory}/idp.crt")) { + File::put("{$directory}/idp.crt", "-----BEGIN CERTIFICATE-----\nMIIE3jCCAsagAwIBAgIQQcyDaZz3MI\n-----END CERTIFICATE-----"); + } +}); + +test('guest is redirected to login when visiting microsoft federation page', function (): void { + $response = $this->get(route('apps.microsoft-federation')); + + $response->assertRedirect(route('login')); +}); + +test('administrator can render microsoft federation page', function (): void { + $response = $this->actingAs($this->admin)->get(route('apps.microsoft-federation')); + + $response->assertStatus(200); +}); + +test('renders warning banner if credentials are not configured in environment', function (): void { + Config::set('services.microsoft_graph', [ + 'tenant_id' => '', + 'client_id' => '', + 'client_secret' => '', + 'domain_name' => '', + ]); + + Livewire::actingAs($this->admin) + ->test('pages::connecton-providers.microsoft-federation') + ->assertSet('credentialsLoaded', false) + ->assertSee('Environment Configuration Required'); +}); + +test('loads credentials and check status successfully if configured', function (): void { + Config::set('services.microsoft_graph', [ + 'tenant_id' => 'mock-tenant-id', + 'client_id' => 'mock-client-id', + 'client_secret' => 'mock-client-secret', + 'domain_name' => 'mockdomain.com', + 'display_name' => 'Company SSO', + 'mfa_behavior' => 'acceptIfMfaDoneByFederatedIdp', + ]); + + // Fake Microsoft Graph API responses + Http::fake([ + 'https://login.microsoftonline.com/mock-tenant-id/oauth2/v2.0/token' => Http::response([ + 'access_token' => 'mock-access-token', + 'token_type' => 'Bearer', + 'expires_in' => 3600, + ], 200), + 'https://graph.microsoft.com/v1.0/domains/mockdomain.com/federationConfiguration' => Http::response([ + 'value' => [ + [ + 'id' => 'mock-federation-id', + 'displayName' => 'Company SSO', + 'issuerUri' => 'http://localhost/saml/metadata', + 'preferredAuthenticationProtocol' => 'saml', + 'federatedIdpMfaBehavior' => 'acceptIfMfaDoneByFederatedIdp', + 'signingCertificate' => 'MIIE3jCCAsagAwIBAgIQQcyDaZz3MI', + ], + ], + ], 200), + ]); + + Livewire::actingAs($this->admin) + ->test('pages::connecton-providers.microsoft-federation') + ->assertSet('credentialsLoaded', true) + ->assertSet('connectionStatus', ConnectionStatusEnum::Connected) + ->assertSet('federationId', 'mock-federation-id') + ->assertSee('Active Federation State'); +}); + +test('can connect/federate domain successfully', function (): void { + Config::set('services.microsoft_graph', [ + 'tenant_id' => 'mock-tenant-id', + 'client_id' => 'mock-client-id', + 'client_secret' => 'mock-client-secret', + 'domain_name' => 'mockdomain.com', + 'display_name' => 'Company SSO', + 'mfa_behavior' => 'acceptIfMfaDoneByFederatedIdp', + ]); + + Http::fake([ + 'https://login.microsoftonline.com/mock-tenant-id/oauth2/v2.0/token' => Http::response([ + 'access_token' => 'mock-access-token', + ], 200), + 'https://graph.microsoft.com/v1.0/domains/mockdomain.com/federationConfiguration' => function ($request) { + if ('GET' === $request->method()) { + return Http::response(['value' => []], 200); + } + if ('POST' === $request->method()) { + return Http::response([ + 'id' => 'new-federation-id', + 'displayName' => 'Company SSO', + 'issuerUri' => 'http://localhost/saml/metadata', + 'preferredAuthenticationProtocol' => 'saml', + 'federatedIdpMfaBehavior' => 'acceptIfMfaDoneByFederatedIdp', + 'signingCertificate' => 'MIIE3jCCAsagAwIBAgIQQcyDaZz3MI', + ], 200); + } + + return Http::response(null, 404); + }, + ]); + + Livewire::actingAs($this->admin) + ->test('pages::connecton-providers.microsoft-federation') + ->set('connectionStatus', ConnectionStatusEnum::Disconnected) + ->call('connect') + ->assertSet('connectionStatus', ConnectionStatusEnum::Connected) + ->assertSet('federationId', 'new-federation-id'); +}); + +test('can disconnect federation successfully', function (): void { + Config::set('services.microsoft_graph', [ + 'tenant_id' => 'mock-tenant-id', + 'client_id' => 'mock-client-id', + 'client_secret' => 'mock-client-secret', + 'domain_name' => 'mockdomain.com', + 'display_name' => 'Company SSO', + 'mfa_behavior' => 'acceptIfMfaDoneByFederatedIdp', + ]); + + Http::fake([ + 'https://login.microsoftonline.com/mock-tenant-id/oauth2/v2.0/token' => Http::response([ + 'access_token' => 'mock-access-token', + ], 200), + 'https://graph.microsoft.com/v1.0/domains/mockdomain.com/federationConfiguration/mock-federation-id' => Http::response(null, 204), + ]); + + Livewire::actingAs($this->admin) + ->test('pages::connecton-providers.microsoft-federation') + ->set('connectionStatus', ConnectionStatusEnum::Connected) + ->set('federationId', 'mock-federation-id') + ->call('disconnect') + ->assertSet('connectionStatus', ConnectionStatusEnum::Disconnected) + ->assertSet('federationId', null); +});