singleloginsystem/app/Services/MicrosoftGraphService.php
= df4a84b970 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.
2026-05-28 12:43:17 +00:00

365 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Data\MicrosoftGraph\MicrosoftGraphCredentialsData;
use Exception;
use Illuminate\Support\Facades\Http;
/**
* 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.
*
* @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,
];
}
}
}