forked from fa/breadcrumb-the-shire
- Move HelpdeskTenantSettingsRepository and HelpdeskTokenRepository from Service/ to Repository/ directory (GR-SEC-003: SQL only in repositories) - Make AccessControl constructor require IntendedUrlService explicitly instead of falling back to `new IntendedUrlService()` (GR-TEST-002: no service instantiation outside factories) - Update all imports and tests accordingly Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
184 lines
5.9 KiB
PHP
184 lines
5.9 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Module\Helpdesk\Service;
|
|
|
|
use MintyPHP\Module\Helpdesk\Repository\HelpdeskTokenRepository;
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|