singleloginsystem/app/Services/MicrosoftGraphService.php
= 215d3cad7f Refactor: Enhance Microsoft Graph Federation error handling and logging
- Improved logging for `MicrosoftGraphService` methods to provide detailed insights into federation configuration processes.
- Refactored error handling for domain federation configuration, including specific checks for authorization and resource errors.
- Streamlined federation flow logic and made it more resilient to exceptions.
- Updated UI components to handle configuration states more effectively and added button functionality for failed connections.
- Expanded test coverage for Microsoft Graph Federation scenarios, including authorization failures and domain validation.
- masked the sensitive fields
2026-06-02 12:25:57 +00:00

563 lines
22 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Data\MicrosoftGraph\MicrosoftGraphCredentialsData;
use Exception;
use Illuminate\Support\Facades\{Http, Log};
/**
* Service to manage Microsoft Graph API domain federation configuration.
*/
final class MicrosoftGraphService
{
/**
* Retrieve federation configuration for the domain.
*
* @return array{status: string, message?: string, config: ?array, transactions: array}
*/
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.',
'config' => null,
'transactions' => $transactions,
];
}
$url = "https://graph.microsoft.com/v1.0/domains/{$credentials->domainName}/federationConfiguration";
$headers = [
'Authorization' => 'Bearer ********',
'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,
'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', []);
$hasConfig = ! empty($value);
Log::info('MicrosoftGraphService: Federation configuration query completed successfully.', [
'config_found' => $hasConfig,
]);
return [
'status' => 'success',
'config' => $hasConfig ? $value[0] : null,
'transactions' => $transactions,
];
}
// 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',
'message' => $errorMsg,
'config' => null,
'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,
'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,
];
}
}
/**
* 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.
*
* @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 {
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.',
'transactions' => $transactions,
];
}
// 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,
'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}";
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,
'method' => 'DELETE',
'status' => $deleteRes->status(),
'response_body' => $deleteRes->json() ?: ['message' => 'Deleted successfully'],
];
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
}
// 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',
];
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,
'method' => 'POST',
'request_headers' => $headers,
'request_body' => $body,
'status' => $response->status(),
'response_headers' => $response->headers(),
'response_body' => $response->json(),
];
if ($response->successful()) {
Log::info('MicrosoftGraphService: Federation configuration created successfully on Microsoft Entra ID.');
return [
'status' => 'success',
'config' => $response->json(),
'transactions' => $transactions,
];
}
$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',
'message' => $errorMsg,
'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,
'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
{
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.',
'transactions' => $transactions,
];
}
$url = "https://graph.microsoft.com/v1.0/domains/{$credentials->domainName}/federationConfiguration/{$federationId}";
$headers = [
'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,
'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()) {
Log::info('MicrosoftGraphService: Federation configuration deleted successfully on Microsoft Entra ID. Domain returned to Managed.');
return [
'status' => 'success',
'transactions' => $transactions,
];
}
$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',
'message' => $errorMsg,
'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,
'method' => 'DELETE',
'request_headers' => $headers,
'request_body' => null,
'status' => 'ERROR',
'response_body' => ['error' => $e->getMessage()],
];
return [
'status' => 'error',
'message' => $e->getMessage(),
'transactions' => $transactions,
];
}
}
}