1
0
Files
breadcrumb-the-shire/modules/security/lib/Module/Security/Service/SecurityOAuthTokenService.php

176 lines
5.6 KiB
PHP

<?php
namespace MintyPHP\Module\Security\Service;
use MintyPHP\Module\Security\Repository\SecurityTokenRepository;
/**
* OAuth2 client_credentials token service for the Security BC gateway.
*
* Tenant-scoped encrypted token cache. Supports v2 endpoints (scope) and legacy
* v1 endpoints (resource) with BC-oriented defaults.
*/
class SecurityOAuthTokenService
{
private const CONNECT_TIMEOUT = 10;
private const REQUEST_TIMEOUT = 30;
public function __construct(
private readonly SecurityBcSettingsGateway $settingsGateway,
private readonly SecurityTokenRepository $tokenRepository
) {
}
public function getAccessToken(int $tenantId, ?string $resourceAudience = null): ?string
{
if ($this->settingsGateway->getAuthMode() !== SecurityBcSettingsGateway::AUTH_MODE_OAUTH2) {
return null;
}
if ($tenantId <= 0) {
return null;
}
$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
{
foreach ($this->buildPayloadCandidates($endpoint, $clientId, $clientSecret, $resourceAudience) 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;
}
}