Introduce per-tenant override for helpdesk BC connection config. New table helpdesk_tenant_settings stores tenant-specific credentials with encrypted secrets (AES-256-GCM). EffectiveHelpdeskSettingsService resolves global vs tenant config per request. Settings UI extended with tenant override tab, toggle, and dual editing mode. All runtime consumers (OData, SOAP, OAuth) read through the new resolver. Token cache flushed on any config change. Default behavior unchanged — tenants use global config until override explicitly enabled. Also includes risk radar UI refinements: Stripe-style card redesign with accent borders and score pills, removal of redundant KPI tiles and search, and filtering of zero-score entries. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
182 lines
5.9 KiB
PHP
182 lines
5.9 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Module\Helpdesk\Service;
|
|
|
|
/**
|
|
* OAuth2 client_credentials token service for BC OData access.
|
|
*
|
|
* Uses tenant-scoped encrypted cache entries via HelpdeskTokenRepository.
|
|
* Supports both v2 endpoints (`scope`) and legacy v1 endpoints (`resource`)
|
|
* with BC-oriented defaults and audience fallback from the configured OData host.
|
|
*/
|
|
class HelpdeskOAuthTokenService
|
|
{
|
|
private const CONNECT_TIMEOUT = 10;
|
|
private const REQUEST_TIMEOUT = 30;
|
|
|
|
public function __construct(
|
|
private readonly EffectiveHelpdeskSettingsService $settingsGateway,
|
|
private readonly HelpdeskTokenRepository $tokenRepository
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* Get a valid access token for BC API calls.
|
|
*
|
|
* @return string|null The bearer token, or null if unavailable.
|
|
*/
|
|
public function getAccessToken(int $tenantId, ?string $resourceAudience = null): ?string
|
|
{
|
|
if ($this->settingsGateway->getAuthMode() !== HelpdeskSettingsGateway::AUTH_MODE_OAUTH2) {
|
|
return null;
|
|
}
|
|
if ($tenantId <= 0) {
|
|
return null;
|
|
}
|
|
|
|
// Check cache first
|
|
$cached = $this->tokenRepository->findValidToken($tenantId);
|
|
if ($cached !== null) {
|
|
return $cached;
|
|
}
|
|
|
|
$clientId = trim((string) ($this->settingsGateway->getOAuthClientId() ?? ''));
|
|
$clientSecret = trim((string) ($this->settingsGateway->getOAuthClientSecret() ?? ''));
|
|
$tokenEndpoint = $this->resolveTokenEndpoint();
|
|
if ($clientId === '' || $clientSecret === '' || $tokenEndpoint === '') {
|
|
return null;
|
|
}
|
|
|
|
$tokenResponse = $this->requestToken($tokenEndpoint, $clientId, $clientSecret, $resourceAudience);
|
|
if (!is_array($tokenResponse)) {
|
|
return null;
|
|
}
|
|
|
|
$accessToken = trim((string) ($tokenResponse['access_token'] ?? ''));
|
|
if ($accessToken === '') {
|
|
return null;
|
|
}
|
|
|
|
$expiresIn = (int) ($tokenResponse['expires_in'] ?? 0);
|
|
if ($expiresIn <= 0) {
|
|
$expiresIn = (int) ($tokenResponse['ext_expires_in'] ?? 0);
|
|
}
|
|
if ($expiresIn <= 0) {
|
|
$expiresIn = 300;
|
|
}
|
|
|
|
$expiresAt = new \DateTimeImmutable('+' . max(60, $expiresIn - 60) . ' seconds');
|
|
$this->tokenRepository->storeToken($tenantId, $accessToken, $expiresAt);
|
|
|
|
return $accessToken;
|
|
}
|
|
|
|
private function resolveTokenEndpoint(): string
|
|
{
|
|
$endpoint = trim((string) ($this->settingsGateway->getOAuthTokenEndpoint() ?? ''));
|
|
if ($endpoint === '') {
|
|
return '';
|
|
}
|
|
|
|
$tenantId = trim((string) ($this->settingsGateway->getOAuthTenantId() ?? ''));
|
|
if ($tenantId !== '') {
|
|
$endpoint = str_replace(['{tenant}', '{tenant_id}'], $tenantId, $endpoint);
|
|
}
|
|
|
|
return $endpoint;
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>|null
|
|
*/
|
|
private function requestToken(string $endpoint, string $clientId, string $clientSecret, ?string $resourceAudience): ?array
|
|
{
|
|
$payloads = $this->buildPayloadCandidates($endpoint, $clientId, $clientSecret, $resourceAudience);
|
|
foreach ($payloads as $payload) {
|
|
$result = $this->requestTokenWithPayload($endpoint, $payload);
|
|
if ($result !== null) {
|
|
return $result;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, string>>
|
|
*/
|
|
private function buildPayloadCandidates(string $endpoint, string $clientId, string $clientSecret, ?string $resourceAudience): array
|
|
{
|
|
$basePayload = [
|
|
'grant_type' => 'client_credentials',
|
|
'client_id' => $clientId,
|
|
'client_secret' => $clientSecret,
|
|
];
|
|
|
|
$payloads = [];
|
|
$isV2Endpoint = str_contains(strtolower($endpoint), '/oauth2/v2.0/token');
|
|
$audience = trim((string) ($resourceAudience ?? ''));
|
|
|
|
if ($isV2Endpoint) {
|
|
$scopes = ['https://api.businesscentral.dynamics.com/.default'];
|
|
if ($audience !== '') {
|
|
$scopes[] = rtrim($audience, '/') . '/.default';
|
|
}
|
|
foreach (array_values(array_unique($scopes)) as $scope) {
|
|
$payloads[] = $basePayload + ['scope' => $scope];
|
|
}
|
|
} else {
|
|
$resources = ['https://api.businesscentral.dynamics.com'];
|
|
if ($audience !== '') {
|
|
$resources[] = $audience;
|
|
}
|
|
foreach (array_values(array_unique($resources)) as $resource) {
|
|
$payloads[] = $basePayload + ['resource' => $resource];
|
|
}
|
|
}
|
|
|
|
$payloads[] = $basePayload;
|
|
|
|
return $payloads;
|
|
}
|
|
|
|
/**
|
|
* @param array<string,string> $payload
|
|
* @return array<string,mixed>|null
|
|
*/
|
|
private function requestTokenWithPayload(string $endpoint, array $payload): ?array
|
|
{
|
|
$ch = curl_init();
|
|
if ($ch === false) {
|
|
return null;
|
|
}
|
|
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_URL => $endpoint,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
|
|
CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
|
|
CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/x-www-form-urlencoded'],
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => http_build_query($payload, '', '&', PHP_QUERY_RFC3986),
|
|
CURLOPT_SSL_VERIFYPEER => true,
|
|
CURLOPT_SSL_VERIFYHOST => 2,
|
|
]);
|
|
|
|
$responseBody = curl_exec($ch);
|
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
unset($ch);
|
|
|
|
if (!is_string($responseBody) || $httpCode < 200 || $httpCode >= 300) {
|
|
return null;
|
|
}
|
|
|
|
$decoded = json_decode($responseBody, true);
|
|
if (!is_array($decoded)) {
|
|
return null;
|
|
}
|
|
|
|
return $decoded;
|
|
}
|
|
}
|