TenantAtlas/app/Services/Graph/MicrosoftGraphClient.php
ahmido 412dd7ad66 feat/017-policy-types-mam-endpoint-security-baselines (#23)
Hydrate configurationPolicies/{id}/settings for endpoint security/baseline policies so snapshots include real rule data.
Treat those types like Settings Catalog policies in the normalizer so they show the searchable settings table, recognizable categories, and readable choice values (firewall-specific formatting + interface badge parsing).
Improve “General” tab cards: badge lists for platforms/technologies, template reference summary (name/family/version/ID), and ISO timestamps rendered as YYYY‑MM‑DD HH:MM:SS; added regression test for the view.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #23
2026-01-03 02:06:35 +00:00

888 lines
32 KiB
PHP

<?php
namespace App\Services\Graph;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Throwable;
class MicrosoftGraphClient implements GraphClientInterface
{
private const DEFAULT_SCOPE = 'https://graph.microsoft.com/.default';
private const MAX_LIST_PAGES = 50;
private string $baseUrl;
private string $tokenUrlTemplate;
private string $tenantId;
private string $clientId;
private string $clientSecret;
private array $defaultScopes = [self::DEFAULT_SCOPE];
private int $timeout;
private int $retryTimes;
private int $retrySleepMs;
public function __construct(
private readonly GraphLogger $logger,
private readonly GraphContractRegistry $contracts,
) {
$this->baseUrl = rtrim(config('graph.base_url', 'https://graph.microsoft.com'), '/')
.'/'.trim(config('graph.version', 'beta'), '/');
$this->tokenUrlTemplate = config('graph.token_url', 'https://login.microsoftonline.com/%s/oauth2/v2.0/token');
$this->tenantId = config('graph.tenant_id', '');
$this->clientId = config('graph.client_id', '');
$this->clientSecret = config('graph.client_secret', '');
$this->defaultScopes = $this->normalizeScopes(config('graph.scope', self::DEFAULT_SCOPE));
$this->timeout = (int) config('graph.timeout', 10);
$this->retryTimes = (int) config('graph.retry.times', 2);
$this->retrySleepMs = (int) config('graph.retry.sleep', 200);
}
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
$endpoint = $this->endpointFor($policyType);
$contract = $this->contracts->get($policyType);
$allowedSelect = is_array($contract['allowed_select'] ?? null) ? $contract['allowed_select'] : [];
$defaultSelect = $options['select'] ?? ($allowedSelect !== [] ? implode(',', $allowedSelect) : null);
$queryInput = array_filter([
'$top' => $options['top'] ?? null,
'$filter' => $options['filter'] ?? null,
'$select' => $defaultSelect,
'platform' => $options['platform'] ?? null,
], fn ($value) => $value !== null && $value !== '');
$sanitized = $this->contracts->sanitizeQuery($policyType, $queryInput);
$query = $sanitized['query'];
$warnings = $sanitized['warnings'];
$context = $this->resolveContext($options);
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
$fullPath = $this->buildFullPath($endpoint, $query);
$this->logger->logRequest('list_policies', [
'endpoint' => $endpoint,
'full_path' => $fullPath,
'method' => 'GET',
'policy_type' => $policyType,
'tenant' => $context['tenant'],
'query' => $query ?: null,
'client_request_id' => $clientRequestId,
]);
$sendOptions = ['query' => $query, 'client_request_id' => $clientRequestId];
if (isset($options['access_token'])) {
$sendOptions['access_token'] = $options['access_token'];
}
$response = $this->send('GET', $endpoint, $sendOptions, $context);
if ($response->failed()) {
$graphResponse = $this->toGraphResponse(
action: 'list_policies',
response: $response,
transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []),
meta: [
'tenant' => $context['tenant'] ?? null,
'path' => $endpoint,
'full_path' => $fullPath,
'method' => 'GET',
'query' => $query ?: null,
'client_request_id' => $clientRequestId,
],
warnings: $warnings,
);
if (! $this->shouldApplySelectFallback($graphResponse, $query)) {
return $graphResponse;
}
$fallbackQuery = array_filter($query, fn ($value, $key) => $key !== '$select', ARRAY_FILTER_USE_BOTH);
$fallbackPath = $this->buildFullPath($endpoint, $fallbackQuery);
$fallbackSendOptions = ['query' => $fallbackQuery, 'client_request_id' => $clientRequestId];
if (isset($options['access_token'])) {
$fallbackSendOptions['access_token'] = $options['access_token'];
}
$this->logger->logRequest('list_policies_fallback', [
'endpoint' => $endpoint,
'full_path' => $fallbackPath,
'method' => 'GET',
'policy_type' => $policyType,
'tenant' => $context['tenant'],
'query' => $fallbackQuery ?: null,
'client_request_id' => $clientRequestId,
]);
$fallbackResponse = $this->send('GET', $endpoint, $fallbackSendOptions, $context);
if ($fallbackResponse->failed()) {
return $this->toGraphResponse(
action: 'list_policies',
response: $fallbackResponse,
transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []),
meta: [
'tenant' => $context['tenant'] ?? null,
'path' => $endpoint,
'full_path' => $fallbackPath,
'method' => 'GET',
'query' => $fallbackQuery ?: null,
'client_request_id' => $clientRequestId,
],
warnings: array_values(array_unique(array_merge(
$warnings,
['Capability fallback applied: removed $select for compatibility.']
))),
);
}
$response = $fallbackResponse;
$query = $fallbackQuery;
$fullPath = $fallbackPath;
$warnings = array_values(array_unique(array_merge(
$warnings,
['Capability fallback applied: removed $select for compatibility.']
)));
}
$json = $response->json() ?? [];
$policies = $json['value'] ?? (is_array($json) ? $json : []);
$nextLink = $json['@odata.nextLink'] ?? null;
$pages = 1;
while (is_string($nextLink) && $nextLink !== '') {
if ($pages >= self::MAX_LIST_PAGES) {
$graphResponse = new GraphResponse(
success: false,
data: [],
status: 500,
errors: [[
'message' => 'Graph pagination exceeded maximum page limit.',
'max_pages' => self::MAX_LIST_PAGES,
]],
warnings: $warnings,
meta: [
'tenant' => $context['tenant'] ?? null,
'path' => $endpoint,
'full_path' => $fullPath,
'method' => 'GET',
'query' => $query ?: null,
'client_request_id' => $clientRequestId,
'pages_fetched' => $pages,
],
);
$this->logger->logResponse('list_policies', $graphResponse, $graphResponse->meta);
return $graphResponse;
}
$pageOptions = ['client_request_id' => $clientRequestId];
if (isset($options['access_token'])) {
$pageOptions['access_token'] = $options['access_token'];
}
$pageResponse = $this->send('GET', $nextLink, $pageOptions, $context);
if ($pageResponse->failed()) {
$graphResponse = $this->toGraphResponse(
action: 'list_policies',
response: $pageResponse,
transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []),
meta: [
'tenant' => $context['tenant'] ?? null,
'path' => $endpoint,
'full_path' => $fullPath,
'method' => 'GET',
'query' => $query ?: null,
'client_request_id' => $clientRequestId,
'pages_fetched' => $pages,
],
warnings: array_values(array_unique(array_merge(
$warnings,
['Pagination failed while listing policies.']
))),
);
return $graphResponse;
}
$pageJson = $pageResponse->json() ?? [];
$pageValue = $pageJson['value'] ?? [];
if (is_array($pageValue) && $pageValue !== []) {
$policies = array_merge($policies, $pageValue);
}
$nextLink = $pageJson['@odata.nextLink'] ?? null;
$pages++;
}
$meta = $this->responseMeta($response, [
'tenant' => $context['tenant'] ?? null,
'path' => $endpoint,
'full_path' => $fullPath,
'method' => 'GET',
'query' => $query ?: null,
'client_request_id' => $clientRequestId,
]);
$meta['pages_fetched'] = $pages;
$meta['item_count'] = count($policies);
if ($pages > 1) {
$warnings = array_values(array_unique(array_merge($warnings, [
sprintf('Pagination applied: fetched %d pages.', $pages),
])));
}
$graphResponse = new GraphResponse(
success: true,
data: $policies,
status: $response->status(),
warnings: $warnings,
meta: $meta,
);
$this->logger->logResponse('list_policies', $graphResponse, $meta);
return $graphResponse;
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
$endpoint = $this->endpointFor($policyType).'/'.urlencode($policyId);
$queryInput = array_filter([
'$select' => $options['select'] ?? null,
'$expand' => $options['expand'] ?? null,
], fn ($value) => $value !== null && $value !== '');
$sanitized = $this->contracts->sanitizeQuery($policyType, $queryInput);
$query = $sanitized['query'];
$warnings = $sanitized['warnings'];
$context = $this->resolveContext($options);
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
$fullPath = $this->buildFullPath($endpoint, $query);
$this->logger->logRequest('get_policy', [
'endpoint' => $endpoint,
'policy_type' => $policyType,
'policy_id' => $policyId,
'tenant' => $context['tenant'],
'full_path' => $fullPath,
'method' => 'GET',
'query' => $query ?: null,
'client_request_id' => $clientRequestId,
]);
$response = $this->send('GET', $endpoint, ['query' => $query, 'client_request_id' => $clientRequestId], $context);
$graphResponse = $this->toGraphResponse(
action: 'get_policy',
response: $response,
transform: fn (array $json) => ['payload' => $json],
meta: [
'tenant' => $context['tenant'] ?? null,
'path' => $endpoint,
'full_path' => $fullPath,
'method' => 'GET',
'query' => $query ?: null,
'client_request_id' => $clientRequestId,
],
warnings: $warnings,
);
if ($graphResponse->failed() && ! empty($query)) {
$fallbackQuery = array_filter($query, fn ($value, $key) => $key !== '$select' && $key !== '$expand', ARRAY_FILTER_USE_BOTH);
$fallbackPath = $this->buildFullPath($endpoint, $fallbackQuery);
$fallbackSendOptions = ['query' => $fallbackQuery, 'client_request_id' => $clientRequestId];
if (isset($options['access_token'])) {
$fallbackSendOptions['access_token'] = $options['access_token'];
}
$this->logger->logRequest('get_policy_fallback', [
'endpoint' => $endpoint,
'policy_type' => $policyType,
'policy_id' => $policyId,
'tenant' => $context['tenant'],
'full_path' => $fallbackPath,
'method' => 'GET',
'query' => $fallbackQuery ?: null,
'client_request_id' => $clientRequestId,
]);
$fallbackResponse = $this->send('GET', $endpoint, $fallbackSendOptions, $context);
$graphResponse = $this->toGraphResponse(
action: 'get_policy',
response: $fallbackResponse,
transform: fn (array $json) => ['payload' => $json],
meta: [
'tenant' => $context['tenant'] ?? null,
'path' => $endpoint,
'full_path' => $fallbackPath,
'method' => 'GET',
'query' => $fallbackQuery ?: null,
'client_request_id' => $clientRequestId,
],
warnings: array_values(array_unique(array_merge(
$warnings,
['Capability fallback applied: removed $select/$expand for compatibility.']
))),
);
}
return $graphResponse;
}
private function shouldApplySelectFallback(GraphResponse $graphResponse, array $query): bool
{
if (! $graphResponse->failed()) {
return false;
}
if (($graphResponse->status ?? null) !== 400) {
return false;
}
if (! array_key_exists('$select', $query)) {
return false;
}
$errorMessage = $graphResponse->meta['error_message'] ?? null;
if (! is_string($errorMessage) || $errorMessage === '') {
return false;
}
if (stripos($errorMessage, 'Parsing OData Select and Expand failed') !== false) {
return true;
}
if (stripos($errorMessage, 'Could not find a property named') !== false) {
return true;
}
return false;
}
public function getOrganization(array $options = []): GraphResponse
{
$context = $this->resolveContext($options);
$endpoint = 'organization';
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
$fullPath = $this->buildFullPath($endpoint);
$this->logger->logRequest('get_organization', [
'endpoint' => $endpoint,
'tenant' => $context['tenant'],
'full_path' => $fullPath,
'method' => 'GET',
'client_request_id' => $clientRequestId,
]);
$response = $this->send('GET', $endpoint, ['client_request_id' => $clientRequestId], $context);
return $this->toGraphResponse(
action: 'get_organization',
response: $response,
transform: fn (array $json) => $json['value'][0] ?? $json,
meta: [
'tenant' => $context['tenant'] ?? null,
'path' => $endpoint,
'full_path' => $fullPath,
'method' => 'GET',
'client_request_id' => $clientRequestId,
]
);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
$endpoint = $this->endpointFor($policyType).'/'.urlencode($policyId);
$method = strtoupper($options['method'] ?? 'PATCH');
$context = $this->resolveContext($options);
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
$fullPath = $this->buildFullPath($endpoint);
$this->logger->logRequest('apply_policy', [
'endpoint' => $endpoint,
'policy_type' => $policyType,
'policy_id' => $policyId,
'tenant' => $context['tenant'],
'method' => $method,
'full_path' => $fullPath,
'client_request_id' => $clientRequestId,
]);
$response = $this->send($method, $endpoint, ['json' => $payload, 'client_request_id' => $clientRequestId], $context);
return $this->toGraphResponse(
action: 'apply_policy',
response: $response,
transform: fn (array $json) => $json,
meta: [
'tenant' => $context['tenant'] ?? null,
'path' => $endpoint,
'full_path' => $fullPath,
'method' => $method,
'client_request_id' => $clientRequestId,
]
);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
$context = $this->resolveContext($options);
$clientId = $context['client_id'];
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
// First, get the service principal object by clientId (appId)
$endpoint = "servicePrincipals?\$filter=appId eq '{$clientId}'";
$this->logger->logRequest('get_service_principal', [
'endpoint' => $endpoint,
'client_id' => $clientId,
'tenant' => $context['tenant'],
'method' => 'GET',
'full_path' => $endpoint,
'client_request_id' => $clientRequestId,
]);
$response = $this->send('GET', $endpoint, ['client_request_id' => $clientRequestId], $context);
if ($response->failed()) {
return $this->toGraphResponse(
action: 'get_service_principal',
response: $response,
transform: fn (array $json) => [],
meta: [
'tenant' => $context['tenant'] ?? null,
'path' => $endpoint,
'full_path' => $endpoint,
'method' => 'GET',
'client_request_id' => $clientRequestId,
]
);
}
$servicePrincipals = $response->json('value', []);
if (empty($servicePrincipals)) {
return new GraphResponse(
success: false,
data: [],
status: 404,
errors: [['message' => 'Service principal not found']],
);
}
$servicePrincipalId = $servicePrincipals[0]['id'] ?? null;
if (! $servicePrincipalId) {
return new GraphResponse(
success: false,
data: [],
status: 500,
errors: [['message' => 'Service principal ID missing']],
);
}
// Now get the app role assignments (application permissions)
$assignmentsEndpoint = "servicePrincipals/{$servicePrincipalId}/appRoleAssignments";
$this->logger->logRequest('get_app_role_assignments', [
'endpoint' => $assignmentsEndpoint,
'service_principal_id' => $servicePrincipalId,
'tenant' => $context['tenant'],
'method' => 'GET',
'full_path' => $assignmentsEndpoint,
'client_request_id' => $clientRequestId,
]);
$assignmentsResponse = $this->send('GET', $assignmentsEndpoint, ['client_request_id' => $clientRequestId], $context);
return $this->toGraphResponse(
action: 'get_service_principal_permissions',
response: $assignmentsResponse,
transform: function (array $json) use ($context) {
$assignments = $json['value'] ?? [];
$permissions = [];
// Get Microsoft Graph service principal to map role IDs to permission names
$graphSpEndpoint = "servicePrincipals?\$filter=appId eq '00000003-0000-0000-c000-000000000000'";
$graphSpResponse = $this->send('GET', $graphSpEndpoint, [], $context);
$graphSps = $graphSpResponse->json('value', []);
$appRoles = $graphSps[0]['appRoles'] ?? [];
// Map role IDs to permission names
$roleMap = [];
foreach ($appRoles as $role) {
$roleMap[$role['id']] = $role['value'];
}
foreach ($assignments as $assignment) {
$roleId = $assignment['appRoleId'] ?? null;
if ($roleId && isset($roleMap[$roleId])) {
$permissions[] = $roleMap[$roleId];
}
}
return ['permissions' => $permissions];
},
meta: [
'tenant' => $context['tenant'] ?? null,
'path' => $assignmentsEndpoint,
'full_path' => $assignmentsEndpoint,
'method' => 'GET',
'client_request_id' => $clientRequestId,
]
);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
$context = $this->resolveContext($options);
$method = strtoupper($method);
$query = $options['query'] ?? [];
$fullPath = $this->buildFullPath($path, $query);
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
$action = strtolower($method).' '.$fullPath;
$this->logger->logRequest($action, [
'endpoint' => $path,
'full_path' => $fullPath,
'method' => $method,
'tenant' => $context['tenant'],
'query' => $query ?: null,
'client_request_id' => $clientRequestId,
]);
$options['client_request_id'] = $clientRequestId;
try {
$response = $this->send($method, $path, $options, $context);
} catch (RequestException|GraphException $exception) {
$graphResponse = $this->graphResponseFromException($exception, [
'tenant' => $context['tenant'] ?? null,
'path' => $path,
'full_path' => $fullPath,
'method' => $method,
'client_request_id' => $clientRequestId,
'query' => $query ?: null,
]);
$this->logger->logResponse($action, $graphResponse, [
'tenant' => $context['tenant'] ?? null,
'endpoint' => $path,
'full_path' => $fullPath,
'method' => $method,
]);
return $graphResponse;
}
return $this->toGraphResponse(
action: $action,
response: $response,
transform: fn (array $json) => $json,
meta: [
'tenant' => $context['tenant'] ?? null,
'path' => $path,
'full_path' => $fullPath,
'method' => $method,
'query' => $query ?: null,
'client_request_id' => $clientRequestId,
]
);
}
private function send(string $method, string $path, array $options = [], array $context = []): Response
{
$context = $context ?: $this->resolveContext([]);
$token = $options['access_token'] ?? $context['access_token'] ?? null;
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
if (! $token) {
$token = $this->getAccessToken($context);
}
$pending = Http::baseUrl($this->baseUrl)
->acceptJson()
->timeout($this->timeout)
->retry($this->retryTimes, $this->retrySleepMs)
->withToken($token)
->withHeaders([
'client-request-id' => $clientRequestId,
]);
if (! empty($options['query'])) {
$pending = $pending->withQueryParameters($options['query']);
}
try {
$response = $pending->send(
$method,
ltrim($path, '/'),
isset($options['json']) ? ['json' => $options['json']] : []
);
} catch (ConnectionException $exception) {
throw new GraphException(
'Graph connection failed: '.$exception->getMessage(),
null,
['path' => $path, 'method' => $method, 'tenant' => $context['tenant'] ?? null]
);
} catch (RequestException $exception) {
if ($exception->response) {
$response = $exception->response;
} else {
throw GraphErrorMapper::fromThrowable(
$exception,
['path' => $path, 'method' => $method, 'tenant' => $context['tenant'] ?? null]
);
}
} catch (Throwable $throwable) {
throw GraphErrorMapper::fromThrowable(
$throwable,
['path' => $path, 'method' => $method, 'tenant' => $context['tenant'] ?? null]
);
}
return $response;
}
private function toGraphResponse(string $action, Response $response, callable $transform, array $meta = [], array $warnings = []): GraphResponse
{
$json = $response->json() ?? [];
$meta = $this->responseMeta($response, $meta);
if ($response->failed()) {
$error = $response->json('error') ?? $json ?? $response->body();
$graphResponse = new GraphResponse(
success: false,
data: is_array($json) ? $json : [],
status: $response->status(),
errors: is_array($error) ? [$error] : [$error],
warnings: $warnings,
meta: $meta,
);
$this->logger->logResponse($action, $graphResponse, $meta);
return $graphResponse;
}
$graphResponse = new GraphResponse(
success: true,
data: $transform(is_array($json) ? $json : []),
status: $response->status(),
warnings: $warnings,
meta: $meta,
);
$this->logger->logResponse($action, $graphResponse, $meta);
return $graphResponse;
}
private function graphResponseFromException(RequestException|GraphException $exception, array $context = []): GraphResponse
{
if ($exception instanceof RequestException && $exception->response) {
$response = $exception->response;
$json = $response->json() ?? [];
$error = $response->json('error') ?? $json ?? $response->body();
return new GraphResponse(
success: false,
data: is_array($json) ? $json : [],
status: $response->status(),
errors: is_array($error) ? [$error] : [$error],
meta: $this->responseMeta($response, $context),
);
}
$contextualError = [];
if ($exception instanceof GraphException && ! empty($exception->context)) {
$contextualError = $exception->context + ['message' => $exception->getMessage()];
}
return new GraphResponse(
success: false,
data: [],
status: $exception instanceof GraphException ? $exception->status : null,
errors: [$contextualError ?: $exception->getMessage()],
meta: $context,
);
}
/**
* @return array{tenant:string,client_id:string,client_secret:string|null,scope:array<int,string>,token_url:string}
*/
private function resolveContext(array $options): array
{
$tenant = $options['tenant'] ?? $this->tenantId;
$clientId = $options['client_id'] ?? $this->clientId;
$clientSecret = $options['client_secret'] ?? $this->clientSecret;
$tokenUrlTemplate = $options['token_url'] ?? $this->tokenUrlTemplate;
$scopes = $this->normalizeScopes($options['scope'] ?? null);
return [
'tenant' => $tenant,
'client_id' => $clientId,
'client_secret' => $clientSecret,
'scope' => $scopes,
'token_url' => sprintf($tokenUrlTemplate, $tenant),
];
}
/**
* @param array<int, string>|string|null $scope
* @return array<int, string>
*/
private function normalizeScopes(array|string|null $scope): array
{
if ($scope === null) {
return $this->defaultScopes;
}
if (is_string($scope)) {
$scope = array_values(array_filter(explode(' ', $scope)));
}
if (is_array($scope)) {
$scope = array_values(array_filter($scope, static fn ($value) => ! empty($value)));
}
return $scope ?: $this->defaultScopes;
}
private function endpointFor(string $policyType): string
{
$contractResource = $this->contracts->resourcePath($policyType);
if (is_string($contractResource) && $contractResource !== '') {
return $contractResource;
}
$supported = config('tenantpilot.supported_policy_types', []);
foreach ($supported as $type) {
if (($type['type'] ?? null) === $policyType && ! empty($type['endpoint'])) {
return $type['endpoint'];
}
}
return 'deviceManagement/'.$policyType;
}
private function getAccessToken(array $context): string
{
$tenant = $context['tenant'] ?? $this->tenantId;
$clientId = $context['client_id'] ?? $this->clientId;
$scopes = $context['scope'] ?? $this->defaultScopes;
$cacheKey = sprintf('graph_token_%s_%s_%s', $tenant, $clientId, md5(implode('|', $scopes)));
if ($token = Cache::get($cacheKey)) {
return $token;
}
[$token, $ttl] = $this->requestAccessToken($context);
Cache::put($cacheKey, $token, now()->addSeconds($ttl));
return $token;
}
/**
* @return array{0:string,1:int} [token, ttlSeconds]
*/
private function requestAccessToken(array $context): array
{
$tenant = $context['tenant'] ?? $this->tenantId;
$clientId = $context['client_id'] ?? $this->clientId;
$clientSecret = $context['client_secret'] ?? $this->clientSecret;
$scopes = $context['scope'] ?? $this->defaultScopes;
$tokenUrl = $context['token_url'] ?? sprintf($this->tokenUrlTemplate, $tenant);
$response = Http::asForm()
->timeout($this->timeout)
->retry($this->retryTimes, $this->retrySleepMs)
->post($tokenUrl, [
'client_id' => $clientId,
'scope' => implode(' ', $scopes),
'client_secret' => $clientSecret,
'grant_type' => 'client_credentials',
]);
if ($response->failed()) {
$error = $response->json('error') ?? $response->json() ?? $response->body();
throw new GraphException(
'Unable to fetch Graph token',
$response->status(),
['error' => $error]
);
}
$data = $response->json();
$expiresIn = (int) ($data['expires_in'] ?? 300);
$ttl = max(60, $expiresIn - 60);
return [(string) $data['access_token'], $ttl];
}
private function buildFullPath(string $path, array $query = []): string
{
$path = ltrim($path, '/');
if (empty($query)) {
return $path;
}
return sprintf('%s?%s', $path, http_build_query($query));
}
/**
* @return array<string, mixed>
*/
private function responseMeta(Response $response, array $context = []): array
{
$requestId = $response->header('request-id') ?? $response->header('x-ms-request-id');
$clientRequestId = $response->header('client-request-id') ?? $context['client_request_id'] ?? null;
$bodyExcerpt = Str::limit((string) $response->body(), 2000);
$jsonError = $response->json('error');
$errorCode = is_array($jsonError) ? ($jsonError['code'] ?? null) : null;
$errorMessage = is_array($jsonError) ? ($jsonError['message'] ?? null) : (is_string($jsonError) ? $jsonError : null);
return array_filter([
'tenant' => $context['tenant'] ?? null,
'path' => $context['path'] ?? null,
'full_path' => $context['full_path'] ?? null,
'method' => $context['method'] ?? null,
'query' => $context['query'] ?? null,
'request_id' => $requestId,
'client_request_id' => $clientRequestId,
'body_excerpt' => $bodyExcerpt,
'error_code' => $errorCode,
'error_message' => $errorMessage,
], static fn ($value) => $value !== null && $value !== '');
}
}