diff --git a/app/Services/MicrosoftGraphService.php b/app/Services/MicrosoftGraphService.php index 5a75440..077cccf 100644 --- a/app/Services/MicrosoftGraphService.php +++ b/app/Services/MicrosoftGraphService.php @@ -6,71 +6,13 @@ use App\Data\MicrosoftGraph\MicrosoftGraphCredentialsData; use Exception; -use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\{Http, Log}; /** * Service to manage Microsoft Graph API domain federation configuration. */ final class MicrosoftGraphService { - /** - * Get an access token using Client Credentials grant flow. - * - * @return array{token: ?string, transaction: array} - */ - public function getAccessToken(MicrosoftGraphCredentialsData $credentials): array - { - $url = "https://login.microsoftonline.com/{$credentials->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. * @@ -78,10 +20,16 @@ public function getAccessToken(MicrosoftGraphCredentialsData $credentials): arra */ public function getFederationConfig(MicrosoftGraphCredentialsData $credentials): array { + Log::info('MicrosoftGraphService: Initiating getFederationConfig for domain.', [ + 'domain_name' => $credentials->domainName, + ]); + $auth = $this->getAccessToken($credentials); $transactions = [$auth['transaction']]; if (! $auth['token']) { + Log::error('MicrosoftGraphService: Failed to retrieve access token, aborting federation config retrieval.'); + return [ 'status' => 'error', 'message' => 'Authentication with Microsoft Graph failed.', @@ -96,9 +44,17 @@ public function getFederationConfig(MicrosoftGraphCredentialsData $credentials): 'Content-Type' => 'application/json', ]; + Log::info('MicrosoftGraphService: Sending GET request to Microsoft Graph for federation configuration.', [ + 'url' => $url, + ]); + try { $response = Http::withToken($auth['token'])->get($url); + Log::info('MicrosoftGraphService: Received federation configuration response.', [ + 'status_code' => $response->status(), + ]); + $transactions[] = [ 'name' => 'Get Federation Configuration', 'url' => $url, @@ -113,16 +69,66 @@ public function getFederationConfig(MicrosoftGraphCredentialsData $credentials): if ($response->successful()) { /** @var array $value */ $value = $response->json('value', []); + $hasConfig = ! empty($value); + + Log::info('MicrosoftGraphService: Federation configuration query completed successfully.', [ + 'config_found' => $hasConfig, + ]); return [ 'status' => 'success', - 'config' => ! empty($value) ? $value[0] : null, + 'config' => $hasConfig ? $value[0] : null, 'transactions' => $transactions, ]; } - /** @var string $errorMsg */ - $errorMsg = $response->json('error.message', 'Failed to retrieve federation config.'); + // Handle standard 404 Request_ResourceNotFound + $errorJson = $response->json('error'); + $errorCode = $errorJson['code'] ?? 'Unknown'; + $errorMsg = $errorJson['message'] ?? 'Failed to retrieve federation config.'; + $innerError = $errorJson['innerError'] ?? null; + + if ('Authorization_RequestDenied' === $errorCode) { + $errorMsg = "Insufficient privileges to complete the operation. Please verify that your Microsoft Entra application registration has been granted the 'Domain-InternalFederation.ReadWrite.All' Application Permission, and that a Global Admin has clicked 'Grant admin consent' in the Entra ID portal."; + } + + if (404 === $response->status() && 'Request_ResourceNotFound' === $errorCode) { + // If it is a domain-not-found error rather than federationConfiguration-not-found: + if ( + (str_contains(mb_strtolower($errorMsg), 'does not exist') && ! str_contains(mb_strtolower($errorMsg), 'federationconfiguration')) || + str_contains(mb_strtolower($errorMsg), '\'{0}\'') + ) { + Log::error('MicrosoftGraphService: The specified domain does not exist in Microsoft Entra ID.', [ + 'domain' => $credentials->domainName, + 'error_message' => $errorMsg, + ]); + + return [ + 'status' => 'error', + 'message' => "The domain '{$credentials->domainName}' does not exist or is not verified in your Microsoft Entra ID tenant. Please add and verify the domain in the Entra portal first.", + 'config' => null, + 'transactions' => $transactions, + ]; + } + + Log::info('MicrosoftGraphService: Domain is currently in a Managed state (no federation configuration exists).', [ + 'status_code' => 404, + 'error_code' => $errorCode, + ]); + + return [ + 'status' => 'success', + 'config' => null, + 'transactions' => $transactions, + ]; + } + + Log::error('MicrosoftGraphService: Microsoft Graph API returned an error response.', [ + 'status_code' => $response->status(), + 'error_code' => $errorCode, + 'error_message' => $errorMsg, + 'inner_error' => $innerError, + ]); return [ 'status' => 'error', @@ -131,6 +137,11 @@ public function getFederationConfig(MicrosoftGraphCredentialsData $credentials): 'transactions' => $transactions, ]; } catch (Exception $e) { + Log::error('MicrosoftGraphService: Exception during federation config retrieval.', [ + 'message' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + $transactions[] = [ 'name' => 'Get Federation Configuration', 'url' => $url, @@ -150,6 +161,90 @@ public function getFederationConfig(MicrosoftGraphCredentialsData $credentials): } } + /** + * Get an access token using Client Credentials grant flow. + * + * @return array{token: ?string, transaction: array} + */ + public function getAccessToken(MicrosoftGraphCredentialsData $credentials): array + { + $url = "https://login.microsoftonline.com/{$credentials->tenantId}/oauth2/v2.0/token"; + + Log::info('MicrosoftGraphService: Starting OAuth 2.0 Client Credentials token acquisition.', [ + 'url' => $url, + 'tenant_id' => $credentials->tenantId, + 'client_id' => $credentials->clientId, + ]); + + $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 { + Log::debug('MicrosoftGraphService: Sending POST token request to login.microsoftonline.com.'); + $response = Http::asForm()->post($url, $body); + + Log::info('MicrosoftGraphService: OAuth 2.0 token response received.', [ + 'status_code' => $response->status(), + ]); + + $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()) { + $accessToken = $response->json('access_token'); + Log::info('MicrosoftGraphService: OAuth 2.0 token retrieved successfully.', [ + 'token_length' => mb_strlen($accessToken), + ]); + + return [ + 'token' => $accessToken, + 'transaction' => $transaction, + ]; + } + + Log::error('MicrosoftGraphService: OAuth 2.0 token request failed.', [ + 'status' => $response->status(), + 'error_payload' => $response->json(), + ]); + } catch (Exception $e) { + Log::error('MicrosoftGraphService: Exception encountered during token retrieval.', [ + 'message' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + $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, + ]; + } + /** * Connect/Federate the domain. * @@ -163,10 +258,20 @@ public function connectFederation( string $activeSignInUri, string $signOutUri ): array { + Log::info('MicrosoftGraphService: Initiating connectFederation.', [ + 'domain_name' => $credentials->domainName, + 'issuer_uri' => $issuerUri, + 'passive_signin_uri' => $passiveSignInUri, + 'active_signin_uri' => $activeSignInUri, + 'signout_uri' => $signOutUri, + ]); + $auth = $this->getAccessToken($credentials); $transactions = [$auth['transaction']]; if (! $auth['token']) { + Log::error('MicrosoftGraphService: Failed to retrieve access token for connectFederation.'); + return [ 'status' => 'error', 'message' => 'Authentication with Microsoft Graph failed.', @@ -176,8 +281,16 @@ public function connectFederation( // Check and delete existing configuration to ensure a clean setup $checkUrl = "https://graph.microsoft.com/v1.0/domains/{$credentials->domainName}/federationConfiguration"; + Log::info('MicrosoftGraphService: Checking for pre-existing federation configuration before connecting.', [ + 'check_url' => $checkUrl, + ]); + try { $checkRes = Http::withToken($auth['token'])->get($checkUrl); + Log::info('MicrosoftGraphService: Pre-check status response received.', [ + 'status_code' => $checkRes->status(), + ]); + $transactions[] = [ 'name' => 'Check Existing Federation Configuration', 'url' => $checkUrl, @@ -193,7 +306,16 @@ public function connectFederation( /** @var string $existingId */ $existingId = $configs[0]['id']; $deleteUrl = "https://graph.microsoft.com/v1.0/domains/{$credentials->domainName}/federationConfiguration/{$existingId}"; + Log::info('MicrosoftGraphService: Pre-existing configuration detected. Triggering deletion to clean state.', [ + 'existing_id' => $existingId, + 'delete_url' => $deleteUrl, + ]); + $deleteRes = Http::withToken($auth['token'])->delete($deleteUrl); + Log::info('MicrosoftGraphService: Pre-existing configuration deletion response.', [ + 'status_code' => $deleteRes->status(), + ]); + $transactions[] = [ 'name' => 'Delete Pre-existing Federation Configuration', 'url' => $deleteUrl, @@ -203,15 +325,31 @@ public function connectFederation( ]; if (! $deleteRes->successful()) { + Log::error('MicrosoftGraphService: Failed to delete pre-existing federation configuration.', [ + 'status_code' => $deleteRes->status(), + 'response_body' => $deleteRes->json(), + ]); + return [ 'status' => 'error', 'message' => 'Failed to delete pre-existing federation configuration.', 'transactions' => $transactions, ]; } + Log::info('MicrosoftGraphService: Pre-existing federation configuration deleted successfully.'); + } else { + Log::info('MicrosoftGraphService: No pre-existing federation configuration found.'); } + } else { + Log::warning('MicrosoftGraphService: Pre-existing check failed, proceeding anyway.', [ + 'status_code' => $checkRes->status(), + 'response' => $checkRes->json(), + ]); } } catch (Exception $e) { + Log::warning('MicrosoftGraphService: Exception during pre-existing configuration check/cleanup.', [ + 'message' => $e->getMessage(), + ]); // Suppress exception and proceed } @@ -242,9 +380,20 @@ public function connectFederation( 'Content-Type' => 'application/json', ]; + Log::info('MicrosoftGraphService: Sending POST request to create federation configuration on Microsoft Entra ID.', [ + 'url' => $url, + 'display_name' => $credentials->displayName, + 'mfa_behavior' => $credentials->mfaBehavior->value, + ]); + try { $response = Http::withToken($auth['token'])->post($url, $body); + Log::info('MicrosoftGraphService: Received federation creation response.', [ + 'status_code' => $response->status(), + 'body' => $response->json(), + ]); + $transactions[] = [ 'name' => 'Create Federation Configuration', 'url' => $url, @@ -257,6 +406,8 @@ public function connectFederation( ]; if ($response->successful()) { + Log::info('MicrosoftGraphService: Federation configuration created successfully on Microsoft Entra ID.'); + return [ 'status' => 'success', 'config' => $response->json(), @@ -264,8 +415,18 @@ public function connectFederation( ]; } - /** @var string $errorMsg */ - $errorMsg = $response->json('error.message', 'Failed to create federation configuration.'); + $errorJson = $response->json('error'); + $errorCode = $errorJson['code'] ?? 'Unknown'; + $errorMsg = $errorJson['message'] ?? 'Failed to create federation configuration.'; + + if ('Authorization_RequestDenied' === $errorCode) { + $errorMsg = "Insufficient privileges to complete the operation."; + } + Log::error('MicrosoftGraphService: Federation configuration creation failed on Microsoft Entra ID.', [ + 'status_code' => $response->status(), + 'error_code' => $errorCode, + 'error_message' => $errorMsg, + ]); return [ 'status' => 'error', @@ -273,6 +434,11 @@ public function connectFederation( 'transactions' => $transactions, ]; } catch (Exception $e) { + Log::error('MicrosoftGraphService: Exception during federation configuration creation.', [ + 'message' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + $transactions[] = [ 'name' => 'Create Federation Configuration', 'url' => $url, @@ -298,10 +464,17 @@ public function connectFederation( */ public function disconnectFederation(MicrosoftGraphCredentialsData $credentials, string $federationId): array { + Log::info('MicrosoftGraphService: Initiating disconnectFederation.', [ + 'domain_name' => $credentials->domainName, + 'federation_id' => $federationId, + ]); + $auth = $this->getAccessToken($credentials); $transactions = [$auth['transaction']]; if (! $auth['token']) { + Log::error('MicrosoftGraphService: Failed to retrieve access token for disconnectFederation.'); + return [ 'status' => 'error', 'message' => 'Authentication with Microsoft Graph failed.', @@ -314,9 +487,17 @@ public function disconnectFederation(MicrosoftGraphCredentialsData $credentials, 'Authorization' => 'Bearer ********', ]; + Log::info('MicrosoftGraphService: Sending DELETE request to Microsoft Graph for federation configuration.', [ + 'url' => $url, + ]); + try { $response = Http::withToken($auth['token'])->delete($url); + Log::info('MicrosoftGraphService: Received federation disconnection response.', [ + 'status_code' => $response->status(), + ]); + $transactions[] = [ 'name' => 'Delete Federation Configuration', 'url' => $url, @@ -329,14 +510,26 @@ public function disconnectFederation(MicrosoftGraphCredentialsData $credentials, ]; if ($response->successful()) { + Log::info('MicrosoftGraphService: Federation configuration deleted successfully on Microsoft Entra ID. Domain returned to Managed.'); + return [ 'status' => 'success', 'transactions' => $transactions, ]; } - /** @var string $errorMsg */ - $errorMsg = $response->json('error.message', 'Failed to remove federation configuration.'); + $errorJson = $response->json('error'); + $errorCode = $errorJson['code'] ?? 'Unknown'; + $errorMsg = $errorJson['message'] ?? 'Failed to remove federation configuration.'; + + if ('Authorization_RequestDenied' === $errorCode) { + $errorMsg = "Insufficient privileges to complete the operation. Please verify that your Microsoft Entra application registration has been granted the 'Domain-InternalFederation.ReadWrite.All' Application Permission, and that a Global Admin has clicked 'Grant admin consent' in the Entra ID portal."; + } + Log::error('MicrosoftGraphService: Federation configuration deletion failed.', [ + 'status_code' => $response->status(), + 'error_code' => $errorCode, + 'error_message' => $errorMsg, + ]); return [ 'status' => 'error', @@ -344,6 +537,11 @@ public function disconnectFederation(MicrosoftGraphCredentialsData $credentials, 'transactions' => $transactions, ]; } catch (Exception $e) { + Log::error('MicrosoftGraphService: Exception during federation configuration deletion.', [ + 'message' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + $transactions[] = [ 'name' => 'Delete Federation Configuration', 'url' => $url, diff --git a/resources/views/pages/connecton-providers/⚡microsoft-federation.blade.php b/resources/views/pages/connecton-providers/⚡microsoft-federation.blade.php index 08c993c..9cb770c 100644 --- a/resources/views/pages/connecton-providers/⚡microsoft-federation.blade.php +++ b/resources/views/pages/connecton-providers/⚡microsoft-federation.blade.php @@ -78,7 +78,7 @@ public function mount(): void } if ($this->credentialsLoaded) { - $this->checkStatus(true); + $this->checkStatus(false); } } @@ -324,13 +324,15 @@ class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semi
- MICROSOFT_GRAPH_TENANT_ID
- MICROSOFT_GRAPH_CLIENT_ID
@@ -397,7 +399,7 @@ class="bg-gray-50/50 text-xs"
+ :disabled="!$credentialsLoaded" class="btn-outline"> Check Status @@ -409,7 +411,7 @@ class="flex flex-wrap items-center justify-between gap-4 border-t border-gray-10 @else Connect & Federate @@ -456,8 +458,17 @@ class="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mx-au

No Active Federation

-

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

+

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

+ + Create Federation +
@endif
diff --git a/tests/Feature/MicrosoftGraphFederationTest.php b/tests/Feature/MicrosoftGraphFederationTest.php index 1bdafb5..606c250 100644 --- a/tests/Feature/MicrosoftGraphFederationTest.php +++ b/tests/Feature/MicrosoftGraphFederationTest.php @@ -152,3 +152,103 @@ ->assertSet('connectionStatus', ConnectionStatusEnum::Disconnected) ->assertSet('federationId', null); }); + +test('renders error state if status check fails due to authorization request denied', 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' => Http::response([ + 'error' => [ + 'code' => 'Authorization_RequestDenied', + 'message' => 'Insufficient privileges to complete the operation.', + 'innerError' => [ + 'date' => '2026-06-02T09:06:37', + 'request-id' => '7f6c85c5-4a55-47f8-91dd-f62e264583e7', + ], + ], + ], 403), + ]); + + Livewire::actingAs($this->admin) + ->test('pages::connecton-providers.microsoft-federation') + ->assertSet('connectionStatus', ConnectionStatusEnum::Error) + ->assertSee('Insufficient privileges to complete the operation.'); +}); + +test('renders managed state if status check returns Request_ResourceNotFound 404', 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' => Http::response([ + 'error' => [ + 'code' => 'Request_ResourceNotFound', + 'message' => "Resource 'federationConfiguration' does not exist or one of its queried reference-property objects are not present.", + 'innerError' => [ + 'date' => '2026-06-02T09:40:58', + 'request-id' => 'e7f31a03-80bf-4b42-b1ca-6af8c156fed7', + 'client-request-id' => 'e7f31a03-80bf-4b42-b1ca-6af8c156fed7', + ], + ], + ], 404), + ]); + + Livewire::actingAs($this->admin) + ->test('pages::connecton-providers.microsoft-federation') + ->assertSet('connectionStatus', ConnectionStatusEnum::Disconnected) + ->assertSet('federationId', null) + ->assertSee('No Active Federation') + ->assertSee('Managed'); +}); + +test('renders error state if status check fails due to domain not found in Entra ID', 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' => Http::response([ + 'error' => [ + 'code' => 'Request_ResourceNotFound', + 'message' => "Resource '{0}' does not exist or one of its queried reference-property objects are not present.", + 'innerError' => [ + 'date' => '2026-06-02T09:50:46', + 'request-id' => '08b35000-608d-4ccd-8caf-8ea42fd13812', + 'client-request-id' => '08b35000-608d-4ccd-8caf-8ea42fd13812', + ], + ], + ], 404), + ]); + + Livewire::actingAs($this->admin) + ->test('pages::connecton-providers.microsoft-federation') + ->assertSet('connectionStatus', ConnectionStatusEnum::Error) + ->assertSee("Resource '{0}' does not exist"); +});