forked from fa/breadcrumb-the-shire
feat(helpdesk): enable oauth2 token flow and tenant-scoped cache
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user