175 lines
5.6 KiB
PHP
175 lines
5.6 KiB
PHP
<?php
|
||
|
||
namespace App\Libraries\Radar;
|
||
|
||
/**
|
||
* JWT + access token Google Workspace (Domain-Wide Delegation).
|
||
* Même pattern que application/libraries/GCalService.php (MSOP-4).
|
||
*/
|
||
class GoogleWorkspaceToken
|
||
{
|
||
private string $jsonPath;
|
||
private string $tokenUri = 'https://oauth2.googleapis.com/token';
|
||
|
||
public function __construct(string $jsonPath)
|
||
{
|
||
$this->jsonPath = $jsonPath;
|
||
if (! \is_file($this->jsonPath)) {
|
||
throw new \RuntimeException('Service account JSON introuvable: ' . $this->jsonPath);
|
||
}
|
||
}
|
||
|
||
public function getAccessToken(string $impersonateEmail, string $scopes): string
|
||
{
|
||
$creds = \json_decode((string) \file_get_contents($this->jsonPath), true);
|
||
if (! \is_array($creds) || empty($creds['client_email']) || empty($creds['private_key'])) {
|
||
throw new \RuntimeException('service_account.json invalide');
|
||
}
|
||
|
||
$now = \time();
|
||
$header = ['alg' => 'RS256', 'typ' => 'JWT'];
|
||
$claim = [
|
||
'iss' => $creds['client_email'],
|
||
'scope' => $scopes,
|
||
'aud' => $this->tokenUri,
|
||
'exp' => $now + 3600,
|
||
'iat' => $now,
|
||
'sub' => $impersonateEmail,
|
||
];
|
||
|
||
$unsigned = $this->b64url(\json_encode($header)) . '.' . $this->b64url(\json_encode($claim));
|
||
$signature = '';
|
||
if (! \openssl_sign($unsigned, $signature, $creds['private_key'], \OPENSSL_ALGO_SHA256)) {
|
||
throw new \RuntimeException('openssl_sign failed');
|
||
}
|
||
$jwt = $unsigned . '.' . $this->b64url($signature);
|
||
|
||
[$code, $body] = $this->http(
|
||
'POST',
|
||
$this->tokenUri,
|
||
['Content-Type: application/x-www-form-urlencoded'],
|
||
\http_build_query([
|
||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||
'assertion' => $jwt,
|
||
])
|
||
);
|
||
|
||
if ($code !== 200) {
|
||
throw new \RuntimeException("Token Google error ({$code}): {$body}");
|
||
}
|
||
|
||
$tok = \json_decode($body, true);
|
||
if (empty($tok['access_token'])) {
|
||
throw new \RuntimeException('Pas d’access_token dans la réponse Google');
|
||
}
|
||
|
||
return $tok['access_token'];
|
||
}
|
||
|
||
/**
|
||
* HTTP sans dépendre de l’ext curl (souvent absente en cPanel).
|
||
*
|
||
* @param list<string> $headers
|
||
* @return array{0:int,1:string}
|
||
*/
|
||
public function http(string $method, string $url, array $headers = [], ?string $body = null): array
|
||
{
|
||
if (\function_exists('curl_init')) {
|
||
return $this->httpCurl($method, $url, $headers, $body);
|
||
}
|
||
|
||
return $this->httpStream($method, $url, $headers, $body);
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $headers
|
||
* @return array{0:int,1:string}
|
||
*/
|
||
private function httpCurl(string $method, string $url, array $headers, ?string $body): array
|
||
{
|
||
$ch = \curl_init($url);
|
||
$opts = [
|
||
\CURLOPT_RETURNTRANSFER => true,
|
||
\CURLOPT_CUSTOMREQUEST => $method,
|
||
\CURLOPT_CONNECTTIMEOUT => 8,
|
||
\CURLOPT_TIMEOUT => 25,
|
||
\CURLOPT_HTTPHEADER => $headers,
|
||
// cPanel : IPv6 cassé → hang / 0 bytes. Forcer IPv4 (comme AiHttp).
|
||
\CURLOPT_IPRESOLVE => \CURL_IPRESOLVE_V4,
|
||
];
|
||
if ($body !== null) {
|
||
$opts[\CURLOPT_POSTFIELDS] = $body;
|
||
}
|
||
\curl_setopt_array($ch, $opts);
|
||
$res = \curl_exec($ch);
|
||
$code = (int) \curl_getinfo($ch, \CURLINFO_HTTP_CODE);
|
||
if ($res === false) {
|
||
$err = \curl_error($ch);
|
||
\curl_close($ch);
|
||
throw new \RuntimeException('cURL error: ' . $err);
|
||
}
|
||
\curl_close($ch);
|
||
|
||
return [$code, $res];
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $headers
|
||
* @return array{0:int,1:string}
|
||
*/
|
||
private function httpStream(string $method, string $url, array $headers, ?string $body): array
|
||
{
|
||
$headerLines = $headers;
|
||
if ($body !== null && $body !== '') {
|
||
$hasContentType = false;
|
||
foreach ($headerLines as $h) {
|
||
if (\stripos($h, 'Content-Type:') === 0) {
|
||
$hasContentType = true;
|
||
break;
|
||
}
|
||
}
|
||
if (! $hasContentType) {
|
||
$headerLines[] = 'Content-Type: application/x-www-form-urlencoded';
|
||
}
|
||
}
|
||
|
||
$opts = [
|
||
'http' => [
|
||
'method' => \strtoupper($method),
|
||
'header' => \implode("\r\n", $headerLines),
|
||
'timeout' => 60,
|
||
'ignore_errors' => true,
|
||
],
|
||
'ssl' => [
|
||
'verify_peer' => true,
|
||
'verify_peer_name' => true,
|
||
],
|
||
];
|
||
if ($body !== null) {
|
||
$opts['http']['content'] = $body;
|
||
}
|
||
|
||
$ctx = \stream_context_create($opts);
|
||
$res = @\file_get_contents($url, false, $ctx);
|
||
$code = 0;
|
||
if (isset($http_response_header) && \is_array($http_response_header)) {
|
||
foreach ($http_response_header as $line) {
|
||
if (\preg_match('#^HTTP/\S+\s+(\d+)#', $line, $m)) {
|
||
$code = (int) $m[1];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if ($res === false) {
|
||
throw new \RuntimeException('HTTP stream error for ' . $url);
|
||
}
|
||
|
||
return [$code, $res];
|
||
}
|
||
|
||
private function b64url(string $data): string
|
||
{
|
||
return \rtrim(\strtr(\base64_encode($data), '+/', '-_'), '=');
|
||
}
|
||
}
|