<?php
declare(strict_types=1);
namespace GNexus\GAuth\OAuth;
use GNexus\GAuth\Config\GAuthConfig;
use GNexus\GAuth\Contract\TokenEndpointInterface;
use GNexus\GAuth\DTO\TokenSet;
use GNexus\GAuth\Exception\TokenExchangeException;
use GNexus\GAuth\Exception\TokenRefreshException;
use GNexus\GAuth\Exception\TokenRevokeException;
use GNexus\GAuth\Exception\TransportException;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Log\LoggerInterface;
final readonly class HttpTokenEndpoint implements TokenEndpointInterface
{
public function __construct(
private GAuthConfig $config,
private ClientInterface $httpClient,
private RequestFactoryInterface $requestFactory,
private StreamFactoryInterface $streamFactory,
private ?LoggerInterface $logger = null,
) {
}
public function exchangeAuthorizationCode(string $code, string $pkceVerifier): TokenSet
{
$payload = [
'grant_type' => 'authorization_code',
'client_id' => $this->config->clientId(),
'client_secret' => $this->config->clientSecret(),
'redirect_uri' => $this->config->redirectUri(),
'code' => $code,
'code_verifier' => $pkceVerifier,
];
$data = $this->sendFormRequest($this->config->tokenUrl(), $payload, TokenExchangeException::class);
return $this->mapTokenSet($data);
}
public function refreshToken(string $refreshToken): TokenSet
{
$payload = [
'grant_type' => 'refresh_token',
'client_id' => $this->config->clientId(),
'client_secret' => $this->config->clientSecret(),
'refresh_token' => $refreshToken,
];
$data = $this->sendFormRequest($this->config->refreshUrl(), $payload, TokenRefreshException::class);
return $this->mapTokenSet($data);
}
public function revokeToken(string $token, ?string $tokenTypeHint = null): void
{
$payload = [
'client_id' => $this->config->clientId(),
'client_secret' => $this->config->clientSecret(),
'token' => $token,
];
if ($tokenTypeHint !== null && $tokenTypeHint !== '') {
$payload['token_type_hint'] = $tokenTypeHint;
}
$this->sendFormRequest($this->config->revokeUrl(), $payload, TokenRevokeException::class, expectJson: false);
}
/**
* @param class-string<\RuntimeException> $exceptionClass
* @return array<string, mixed>
*/
private function sendFormRequest(
string $url,
array $payload,
string $exceptionClass,
bool $expectJson = true,
): array {
$body = http_build_query($payload, arg_separator: '&', encoding_type: PHP_QUERY_RFC3986);
$request = $this->requestFactory->createRequest('POST', $url)
->withHeader('Content-Type', 'application/x-www-form-urlencoded')
->withHeader('Accept', 'application/json');
if ($this->config->userAgent() !== null) {
$request = $request->withHeader('User-Agent', $this->config->userAgent());
}
$request = $request->withBody($this->streamFactory->createStream($body));
try {
$response = $this->httpClient->sendRequest($request);
} catch (ClientExceptionInterface $exception) {
$this->logger?->error('gnexus-auth token transport failure', [
'url' => $url,
'message' => $exception->getMessage(),
]);
throw new TransportException('Request to gnexus-auth failed.', 0, $exception);
}
if ($response->getStatusCode() >= 400) {
$payload = (string) $response->getBody();
$message = $this->extractErrorMessage($payload) ?? 'gnexus-auth returned an error response.';
throw new $exceptionClass($message);
}
if (! $expectJson) {
return [];
}
$decoded = json_decode((string) $response->getBody(), true);
if (! is_array($decoded)) {
throw new $exceptionClass('gnexus-auth returned malformed JSON.');
}
return $decoded;
}
/**
* @param array<string, mixed> $data
*/
private function mapTokenSet(array $data): TokenSet
{
$expiresIn = (int) ($data['expires_in'] ?? 0);
$refreshExpiresIn = isset($data['refresh_expires_in']) ? (int) $data['refresh_expires_in'] : null;
$scope = isset($data['scope']) && is_string($data['scope']) ? preg_split('/\s+/', trim($data['scope'])) : [];
$scopes = array_values(array_filter($scope ?: [], static fn ($item) => $item !== ''));
$expiresAt = $expiresIn > 0 ? (new \DateTimeImmutable())->modify(sprintf('+%d seconds', $expiresIn)) : null;
return new TokenSet(
accessToken: (string) ($data['access_token'] ?? ''),
refreshToken: isset($data['refresh_token']) ? (string) $data['refresh_token'] : null,
tokenType: (string) ($data['token_type'] ?? 'Bearer'),
expiresIn: $expiresIn,
expiresAt: $expiresAt,
refreshExpiresIn: $refreshExpiresIn,
scopes: $scopes,
rawPayload: $data,
);
}
private function extractErrorMessage(string $payload): ?string
{
$decoded = json_decode($payload, true);
if (! is_array($decoded)) {
return null;
}
if (isset($decoded['error_description']) && is_string($decoded['error_description'])) {
return $decoded['error_description'];
}
if (isset($decoded['error']) && is_string($decoded['error'])) {
return $decoded['error'];
}
return null;
}
}