- Implemented `EnumMorphsToOptionsContract` interface to enable enums with options and labels for dropdowns. - Created `SamlNameIdFormatEnum` and `SamlNameIdSourceEnum` for standardized SAML NameID configuration. - Added reusable `SAML Configuration` Blade component for easier integration into connected app forms. - Enabled custom SAML Attribute mapping with dynamic add/remove functionality and validation. - Improved `SamlIdpController` to enforce ACS URL matching and user authorization checks. - Refactored SAML-related tests and added scenarios for role-based SAML access and custom configurations.
563 lines
22 KiB
PHP
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,
|
|
];
|
|
}
|
|
}
|
|
}
|