Newer
Older
gnexus-auth-client-php / src / OAuth / HttpTokenEndpoint.php
@Eugene Sukhodolskiy Eugene Sukhodolskiy 12 hours ago 5 KB Initial auth client package scaffold
<?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;
    }
}