PietroCalOAuth.phpphp/PietroCalOAuth.php · 148 líneas
<?php
declare(strict_types=1);
namespace PietroCal\Sdk;
use JsonException;
use RuntimeException;
/*
* 10Q00.4.1: una solicitud HTTP directa debe mostrar el visor seguro en lugar
* de ejecutar este archivo. Los usos por CLI o mediante require/include no se
* modifican.
*/
if (
PHP_SAPI !== 'cli'
&& isset($_SERVER['SCRIPT_FILENAME'])
&& realpath((string)$_SERVER['SCRIPT_FILENAME']) === __FILE__
) {
header('Cache-Control: no-store');
header('Location: /api/sdk/view.php?file=php%2FPietroCalOAuth.php', true, 302);
exit;
}
final class PietroCalOAuth
{
public function __construct(
private readonly string $clientId,
private readonly string $redirectUri,
private readonly ?string $clientSecret = null,
private readonly string $baseUrl = 'https://pietrocal.com'
) {
if (!str_starts_with($clientId, 'pc_oauth_')) {
throw new RuntimeException('El Client ID debe comenzar con pc_oauth_.');
}
}
public function createPkce(): array
{
$verifier = rtrim(strtr(base64_encode(random_bytes(48)), '+/', '-_'), '=');
$challenge = rtrim(strtr(
base64_encode(hash('sha256', $verifier, true)),
'+/',
'-_'
), '=');
return [
'codeVerifier' => $verifier,
'codeChallenge' => $challenge,
];
}
public function authorizationUrl(array $scopes, string $state, string $codeChallenge): string
{
return rtrim($this->baseUrl, '/') . '/oauth/authorize.php?' . http_build_query([
'response_type' => 'code',
'client_id' => $this->clientId,
'redirect_uri' => $this->redirectUri,
'scope' => implode(' ', array_values(array_unique($scopes))),
'state' => $state,
'code_challenge' => $codeChallenge,
'code_challenge_method' => 'S256',
], '', '&', PHP_QUERY_RFC3986);
}
public function exchangeCode(string $code, string $codeVerifier): array
{
return $this->postForm('/oauth/token.php', [
'grant_type' => 'authorization_code',
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'code' => $code,
'redirect_uri' => $this->redirectUri,
'code_verifier' => $codeVerifier,
]);
}
public function refresh(string $refreshToken): array
{
return $this->postForm('/oauth/token.php', [
'grant_type' => 'refresh_token',
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'refresh_token' => $refreshToken,
]);
}
public function revoke(string $token, ?string $tokenTypeHint = null): void
{
$this->postForm('/oauth/revoke.php', [
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'token' => $token,
'token_type_hint' => $tokenTypeHint,
], true);
}
private function postForm(string $path, array $fields, bool $allowEmpty = false): array
{
$fields = array_filter($fields, static fn ($value): bool => $value !== null && $value !== '');
$curl = curl_init(rtrim($this->baseUrl, '/') . $path);
if ($curl === false) {
throw new RuntimeException('No se pudo inicializar cURL.');
}
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'Content-Type: application/x-www-form-urlencoded',
],
CURLOPT_POSTFIELDS => http_build_query($fields, '', '&', PHP_QUERY_RFC3986),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($curl);
if ($raw === false) {
$message = curl_error($curl);
curl_close($curl);
throw new RuntimeException('Error de red: ' . $message);
}
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
if ($status < 200 || $status >= 300) {
$data = json_decode($raw, true);
throw new RuntimeException(
(string) ($data['error_description'] ?? 'La operación OAuth falló.')
);
}
if ($allowEmpty && trim($raw) === '') {
return [];
}
try {
return json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new RuntimeException('PietroCal devolvió JSON inválido.', 0, $exception);
}
}
}