From 8de67fa8829c3347e5348a6512387041e9a49685 Mon Sep 17 00:00:00 2001 From: fs Date: Thu, 2 Apr 2026 21:46:01 +0200 Subject: [PATCH] feat(helpdesk): enable oauth2 token flow and tenant-scoped cache --- .../Helpdesk/HelpdeskContainerRegistrar.php | 5 +- .../Helpdesk/Service/BcODataGateway.php | 73 ++++++++- .../Service/HelpdeskOAuthTokenService.php | 152 +++++++++++++++++- .../pages/helpdesk/debitor-summary-data().php | 8 +- .../debitor-ticket-categories-data().php | 9 +- .../pages/helpdesk/debitor-tickets-data().php | 8 +- .../Helpdesk/Service/BcODataGatewayTest.php | 10 +- .../Service/HelpdeskOAuthTokenServiceTest.php | 12 ++ modules/helpdesk/web/js/helpdesk-detail.js | 11 ++ 9 files changed, 267 insertions(+), 21 deletions(-) diff --git a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php index d4db45c..1032b31 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php +++ b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php @@ -4,6 +4,7 @@ namespace MintyPHP\Module\Helpdesk; use MintyPHP\App\AppContainer; use MintyPHP\App\Container\ContainerRegistrar; +use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Module\Helpdesk\Service\BcODataGateway; use MintyPHP\Module\Helpdesk\Service\DebitorDetailService; use MintyPHP\Module\Helpdesk\Service\DebitorSearchService; @@ -28,7 +29,9 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar )); $container->set(BcODataGateway::class, static fn (AppContainer $c): BcODataGateway => new BcODataGateway( - $c->get(HelpdeskSettingsGateway::class) + $c->get(HelpdeskSettingsGateway::class), + $c->get(HelpdeskOAuthTokenService::class), + $c->get(SessionStoreInterface::class) )); $container->set(HelpdeskTokenRepository::class, static fn (AppContainer $c): HelpdeskTokenRepository => new HelpdeskTokenRepository( diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php index 30c2de3..8cedd48 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php @@ -2,6 +2,8 @@ namespace MintyPHP\Module\Helpdesk\Service; +use MintyPHP\Http\SessionStoreInterface; + /** * Gateway for Business Central OData V4 API calls. * @@ -20,7 +22,9 @@ class BcODataGateway private const REQUEST_TIMEOUT = 30; public function __construct( - private readonly HelpdeskSettingsGateway $settingsGateway + private readonly HelpdeskSettingsGateway $settingsGateway, + private readonly HelpdeskOAuthTokenService $oauthTokenService, + private readonly SessionStoreInterface $sessionStore ) { } @@ -425,11 +429,12 @@ class BcODataGateway return ['error' => 'Not configured or empty customer number']; } + $escapedCustomerNo = $this->escapeODataString($customerNo); $results = []; // 1. Customer entity $customerUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER) - . '?$filter=' . rawurlencode("No eq '" . $customerNo . "'"); + . '?$filter=' . rawurlencode("No eq '" . $escapedCustomerNo . "'"); $results['customer'] = $this->rawRequest($customerUrl); // Get customer name for contact/ticket filter @@ -441,8 +446,9 @@ class BcODataGateway // 2. Contact entity — filter by Company_Name $contactBaseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTACT); if ($customerName !== '') { + $escapedCustomerName = $this->escapeODataString((string) $customerName); $contactFilteredUrl = $contactBaseUrl - . '?$filter=' . rawurlencode("Company_Name eq '" . $customerName . "'") + . '?$filter=' . rawurlencode("Company_Name eq '" . $escapedCustomerName . "'") . '&$top=10&$select=' . rawurlencode('No,Name,Type,Company_No,Company_Name,E_Mail,Phone_No,Job_Title'); $results['contact_filtered'] = $this->rawRequest($contactFilteredUrl); } else { @@ -452,8 +458,9 @@ class BcODataGateway // 3. Tickets entity (PBI_FP_Tickets) — filter by Company_Contact_Name $ticketsBaseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS); if ($customerName !== '') { + $escapedCustomerName = $this->escapeODataString((string) $customerName); $ticketsFilteredUrl = $ticketsBaseUrl - . '?$filter=' . rawurlencode("Company_Contact_Name eq '" . $customerName . "'") + . '?$filter=' . rawurlencode("Company_Contact_Name eq '" . $escapedCustomerName . "'") . '&$top=10&$orderby=' . rawurlencode('Created_On desc') . '&$select=' . rawurlencode('No,Description,Company_Contact_Name,Ticket_State,Created_On,Last_Activity_Date'); $results['tickets_filtered'] = $this->rawRequest($ticketsFilteredUrl); @@ -629,9 +636,28 @@ class BcODataGateway $password = $this->settingsGateway->getBasicPassword() ?? ''; curl_setopt($ch, CURLOPT_USERPWD, $user . ':' . $password); curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); + return; } - // OAuth2 bearer token mode is handled externally via HelpdeskOAuthTokenService. - // For V1, only Basic Auth is fully implemented. + + if ($authMode !== HelpdeskSettingsGateway::AUTH_MODE_OAUTH2) { + return; + } + + $tenantId = $this->resolveCurrentTenantId(); + if ($tenantId <= 0) { + return; + } + + $token = $this->oauthTokenService->getAccessToken($tenantId, $this->buildOAuthAudience()); + if ($token === null || trim($token) === '') { + return; + } + + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Accept: application/json', + 'OData-Version: 4.0', + 'Authorization: Bearer ' . $token, + ]); } /** @@ -667,4 +693,39 @@ class BcODataGateway return str_replace("'", "''", $value); } + + private function resolveCurrentTenantId(): int + { + $session = $this->sessionStore->all(); + $tenantId = (int) (($session['current_tenant']['id'] ?? 0)); + + return $tenantId > 0 ? $tenantId : 0; + } + + private function buildOAuthAudience(): ?string + { + $baseUrl = trim($this->settingsGateway->getODataBaseUrl()); + if ($baseUrl === '') { + return null; + } + + $parsed = parse_url($baseUrl); + if (!is_array($parsed)) { + return null; + } + + $scheme = strtolower((string) ($parsed['scheme'] ?? 'https')); + $host = trim((string) ($parsed['host'] ?? '')); + if ($host === '') { + return null; + } + + $port = (int) ($parsed['port'] ?? 0); + $authority = $host; + if ($port > 0 && !(($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80))) { + $authority .= ':' . $port; + } + + return $scheme . '://' . $authority; + } } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskOAuthTokenService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskOAuthTokenService.php index 60721f1..52332d2 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskOAuthTokenService.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskOAuthTokenService.php @@ -5,12 +5,15 @@ namespace MintyPHP\Module\Helpdesk\Service; /** * OAuth2 client_credentials token service for BC OData access. * - * This is a skeleton for V1. Basic Auth is the default and recommended - * auth mode. OAuth2 support can be activated in settings but the full - * token flow (request, cache, refresh) is deferred to a future version. + * 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 HelpdeskSettingsGateway $settingsGateway, private readonly HelpdeskTokenRepository $tokenRepository @@ -22,11 +25,14 @@ class HelpdeskOAuthTokenService * * @return string|null The bearer token, or null if unavailable. */ - public function getAccessToken(int $tenantId): ?string + 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); @@ -34,8 +40,142 @@ class HelpdeskOAuthTokenService return $cached; } - // Token request would go here in a future version. - // For V1, return null to indicate OAuth2 is not yet fully implemented. + $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|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> + */ + 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 $payload + * @return array|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; + } } diff --git a/modules/helpdesk/pages/helpdesk/debitor-summary-data().php b/modules/helpdesk/pages/helpdesk/debitor-summary-data().php index 339f473..c2f964b 100644 --- a/modules/helpdesk/pages/helpdesk/debitor-summary-data().php +++ b/modules/helpdesk/pages/helpdesk/debitor-summary-data().php @@ -2,6 +2,7 @@ use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy; use MintyPHP\Module\Helpdesk\Service\DebitorDetailService; +use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Support\Guard; Guard::requireLogin(); @@ -21,8 +22,11 @@ if ($customerNo === '' || $customerName === '') { } // Reuse session cache from tickets endpoint when available (avoids duplicate OData call) -$sessionStore = app(\MintyPHP\Http\SessionStoreInterface::class); -$cacheKey = 'module.helpdesk.tickets_cache.' . $customerNo; +$sessionStore = app(SessionStoreInterface::class); +$session = $sessionStore->all(); +$tenantId = (int) (($session['current_tenant']['id'] ?? 0)); +$tenantScope = $tenantId > 0 ? (string) $tenantId : 'global'; +$cacheKey = 'module.helpdesk.tickets_cache.' . $tenantScope . '.' . $customerNo; $cacheTtl = 120; $cached = $sessionStore->get($cacheKey); diff --git a/modules/helpdesk/pages/helpdesk/debitor-ticket-categories-data().php b/modules/helpdesk/pages/helpdesk/debitor-ticket-categories-data().php index 21f7292..11f45b7 100644 --- a/modules/helpdesk/pages/helpdesk/debitor-ticket-categories-data().php +++ b/modules/helpdesk/pages/helpdesk/debitor-ticket-categories-data().php @@ -2,6 +2,7 @@ use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy; use MintyPHP\Module\Helpdesk\Service\DebitorDetailService; +use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Router; use MintyPHP\Support\Guard; @@ -27,8 +28,11 @@ if ($customerNo === '' || $customerName === '') { return; } -$sessionStore = app(\MintyPHP\Http\SessionStoreInterface::class); -$cacheKey = 'module.helpdesk.tickets_cache.' . $customerNo; +$sessionStore = app(SessionStoreInterface::class); +$session = $sessionStore->all(); +$tenantId = (int) (($session['current_tenant']['id'] ?? 0)); +$tenantScope = $tenantId > 0 ? (string) $tenantId : 'global'; +$cacheKey = 'module.helpdesk.tickets_cache.' . $tenantScope . '.' . $customerNo; $cacheTtl = 120; $cached = $sessionStore->get($cacheKey); $allTickets = null; @@ -77,4 +81,3 @@ Router::json([ 'ok' => true, 'categories' => array_values($categoryValues), ]); - diff --git a/modules/helpdesk/pages/helpdesk/debitor-tickets-data().php b/modules/helpdesk/pages/helpdesk/debitor-tickets-data().php index 14b1596..465b29c 100644 --- a/modules/helpdesk/pages/helpdesk/debitor-tickets-data().php +++ b/modules/helpdesk/pages/helpdesk/debitor-tickets-data().php @@ -2,6 +2,7 @@ use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy; use MintyPHP\Module\Helpdesk\Service\DebitorDetailService; +use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Support\Guard; Guard::requireLogin(); @@ -22,8 +23,11 @@ if ($customerNo === '' || $customerName === '') { // --- Session cache (lazy TTL = 120s) --- -$sessionStore = app(\MintyPHP\Http\SessionStoreInterface::class); -$cacheKey = 'module.helpdesk.tickets_cache.' . $customerNo; +$sessionStore = app(SessionStoreInterface::class); +$session = $sessionStore->all(); +$tenantId = (int) (($session['current_tenant']['id'] ?? 0)); +$tenantScope = $tenantId > 0 ? (string) $tenantId : 'global'; +$cacheKey = 'module.helpdesk.tickets_cache.' . $tenantScope . '.' . $customerNo; $cacheTtl = 120; $cached = $sessionStore->get($cacheKey); $allTickets = null; diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/BcODataGatewayTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/BcODataGatewayTest.php index 968d839..096052f 100644 --- a/modules/helpdesk/tests/Module/Helpdesk/Service/BcODataGatewayTest.php +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/BcODataGatewayTest.php @@ -2,7 +2,9 @@ namespace MintyPHP\Tests\Module\Helpdesk\Service; +use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Module\Helpdesk\Service\BcODataGateway; +use MintyPHP\Module\Helpdesk\Service\HelpdeskOAuthTokenService; use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway; use PHPUnit\Framework\TestCase; @@ -21,7 +23,13 @@ class BcODataGatewayTest extends TestCase $settings->method('getBasicUser')->willReturn('testuser'); $settings->method('getBasicPassword')->willReturn('testpass'); - return new BcODataGateway($settings); + $oauthTokenService = $this->createMock(HelpdeskOAuthTokenService::class); + $sessionStore = $this->createMock(SessionStoreInterface::class); + $sessionStore->method('all')->willReturn([ + 'current_tenant' => ['id' => 1], + ]); + + return new BcODataGateway($settings, $oauthTokenService, $sessionStore); } public function testSearchCustomersReturnsEmptyForEmptyQuery(): void diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/HelpdeskOAuthTokenServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/HelpdeskOAuthTokenServiceTest.php index a53052a..1bb784c 100644 --- a/modules/helpdesk/tests/Module/Helpdesk/Service/HelpdeskOAuthTokenServiceTest.php +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/HelpdeskOAuthTokenServiceTest.php @@ -55,4 +55,16 @@ class HelpdeskOAuthTokenServiceTest extends TestCase $service = new HelpdeskOAuthTokenService($settings, $tokenRepo); $service->getAccessToken(1); } + + public function testDoesNotQueryCacheForInvalidTenantId(): void + { + $settings = $this->createMock(HelpdeskSettingsGateway::class); + $settings->method('getAuthMode')->willReturn(HelpdeskSettingsGateway::AUTH_MODE_OAUTH2); + + $tokenRepo = $this->createMock(HelpdeskTokenRepository::class); + $tokenRepo->expects($this->never())->method('findValidToken'); + + $service = new HelpdeskOAuthTokenService($settings, $tokenRepo); + $this->assertNull($service->getAccessToken(0)); + } } diff --git a/modules/helpdesk/web/js/helpdesk-detail.js b/modules/helpdesk/web/js/helpdesk-detail.js index 78b77f5..8d0ee77 100644 --- a/modules/helpdesk/web/js/helpdesk-detail.js +++ b/modules/helpdesk/web/js/helpdesk-detail.js @@ -539,6 +539,17 @@ function init(container) { updateKpi('open-tickets', String(summaryResult.open ?? 0)); updateKpi('last-activity', summaryResult.last_activity ? formatDate(summaryResult.last_activity) : '—'); updateBadge('tab-badge-tickets', summaryResult.total ?? 0); + } else if (Array.isArray(allTickets)) { + // Fallback: calculate from fetched overview tickets when summary endpoint unavailable + const openCount = allTickets.filter(tk => isOpen(tk.Ticket_State || '')).length; + updateKpi('open-tickets', String(openCount)); + let maxDate = ''; + for (const tk of allTickets) { + const d = tk.Last_Activity_Date || ''; + if (d > maxDate && d !== '0001-01-01T00:00:00Z') maxDate = d; + } + updateKpi('last-activity', maxDate ? formatDate(maxDate) : '—'); + updateBadge('tab-badge-tickets', ticketsResult.total ?? allTickets.length); } if (contactsResult.ok) {