Skip to content

Commit 772379a

Browse files
committed
Add Service Account authentication support
1 parent 5e6c99d commit 772379a

2 files changed

Lines changed: 370 additions & 30 deletions

File tree

src/Google/RestApi.php

Lines changed: 155 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@
44

55
namespace Keboola\Google\ClientBundle\Google;
66

7+
use Google\Auth\CredentialsLoader;
78
use GuzzleHttp\Client;
89
use GuzzleHttp\Handler\CurlHandler;
910
use GuzzleHttp\HandlerStack;
1011
use GuzzleHttp\Psr7\Response;
1112
use Keboola\Google\ClientBundle\Exception\RestApiException;
1213
use Keboola\Google\ClientBundle\Guzzle\RetryCallbackMiddleware;
13-
use Monolog\Logger;
1414
use Psr\Http\Message\RequestInterface;
1515
use Psr\Http\Message\ResponseInterface;
16+
use Psr\Log\LoggerInterface;
17+
use Throwable;
1618

1719
class RestApi
1820
{
@@ -21,17 +23,20 @@ class RestApi
2123
private const DEFAULT_CONNECT_TIMEOUT = 30;
2224
private const DEFAULT_REQUEST_TIMEOUT = 5 * 60;
2325

26+
public const AUTH_TYPE_OAUTH = 'oauth';
27+
public const AUTH_TYPE_SERVICE_ACCOUNT = 'service_account';
28+
2429
/** @var int */
2530
protected $maxBackoffs = 7;
2631

2732
/** @var callable */
2833
protected $backoffCallback403;
2934

3035
/** @var string */
31-
protected $accessToken;
36+
protected $accessToken = '';
3237

3338
/** @var string */
34-
protected $refreshToken;
39+
protected $refreshToken = '';
3540

3641
/** @var string */
3742
protected $clientId;
@@ -45,24 +50,125 @@ class RestApi
4550
/** @var callable */
4651
protected $delayFn = null;
4752

48-
/** @var ?Logger */
53+
/** @var ?LoggerInterface */
4954
protected $logger;
5055

51-
public function __construct(
56+
/** @var string */
57+
protected $authType = self::AUTH_TYPE_OAUTH;
58+
59+
/** @var ?array */
60+
protected $serviceAccountConfig;
61+
62+
/** @var ?array<string> */
63+
protected $scopes;
64+
65+
/** @var mixed */
66+
protected $serviceAccountCredentials;
67+
68+
/** @var ?string */
69+
protected $serviceAccountAccessToken;
70+
71+
/** @var ?int */
72+
protected $serviceAccountTokenExpiry;
73+
74+
public function __construct(?LoggerInterface $logger = null)
75+
{
76+
$this->logger = $logger;
77+
78+
$this->backoffCallback403 = function () {
79+
return true;
80+
};
81+
}
82+
83+
/**
84+
* Factory method for creating REST API client with OAuth authentication
85+
*/
86+
public static function createWithOAuth(
5287
string $clientId,
5388
string $clientSecret,
5489
string $accessToken = '',
5590
string $refreshToken = '',
56-
?Logger $logger = null,
57-
) {
58-
$this->clientId = $clientId;
59-
$this->clientSecret = $clientSecret;
60-
$this->setCredentials($accessToken, $refreshToken);
61-
$this->logger = $logger;
62-
63-
$this->backoffCallback403 = function () {
91+
?LoggerInterface $logger = null,
92+
): self {
93+
$instance = new self($logger);
94+
$instance->authType = self::AUTH_TYPE_OAUTH;
95+
$instance->clientId = $clientId;
96+
$instance->clientSecret = $clientSecret;
97+
$instance->setCredentials($accessToken, $refreshToken);
98+
99+
$instance->backoffCallback403 = function () {
64100
return true;
65101
};
102+
103+
return $instance;
104+
}
105+
106+
/**
107+
* Factory method for creating REST API client with Service Account authentication
108+
*/
109+
public static function createWithServiceAccount(
110+
array $serviceAccountConfig,
111+
array $scopes,
112+
?LoggerInterface $logger = null,
113+
): self {
114+
$instance = new self($logger);
115+
$instance->authType = self::AUTH_TYPE_SERVICE_ACCOUNT;
116+
$instance->serviceAccountConfig = $serviceAccountConfig;
117+
$instance->scopes = $scopes;
118+
$instance->initializeServiceAccountCredentials();
119+
return $instance;
120+
}
121+
122+
/**
123+
* Initialize Service Account credentials using Google Auth SDK
124+
*/
125+
protected function initializeServiceAccountCredentials(): void
126+
{
127+
if ($this->serviceAccountConfig === null || empty($this->scopes)) {
128+
throw new RestApiException('Service account configuration and scopes are required', 400);
129+
}
130+
131+
try {
132+
$this->serviceAccountCredentials = CredentialsLoader::makeCredentials(
133+
$this->scopes,
134+
$this->serviceAccountConfig,
135+
);
136+
} catch (Throwable $e) {
137+
throw new RestApiException('Failed to initialize service account credentials: ' . $e->getMessage(), 500);
138+
}
139+
}
140+
141+
/**
142+
* Get access token for Service Account
143+
*/
144+
protected function getServiceAccountAccessToken(): string
145+
{
146+
// Return cached token if still valid
147+
if ($this->serviceAccountAccessToken !== null &&
148+
$this->serviceAccountTokenExpiry !== null &&
149+
time() < $this->serviceAccountTokenExpiry - 60) { // 60s buffer
150+
return $this->serviceAccountAccessToken;
151+
}
152+
153+
if ($this->serviceAccountCredentials === null) {
154+
throw new RestApiException('Service account credentials not initialized', 500);
155+
}
156+
157+
try {
158+
// Fetch new access token using Google Auth SDK
159+
$authToken = $this->serviceAccountCredentials->fetchAuthToken();
160+
161+
if (!isset($authToken['access_token'])) {
162+
throw new RestApiException('Failed to retrieve access token from service account', 500);
163+
}
164+
165+
$this->serviceAccountAccessToken = $authToken['access_token'];
166+
$this->serviceAccountTokenExpiry = time() + ($authToken['expires_in'] ?? 3600);
167+
168+
return $this->serviceAccountAccessToken;
169+
} catch (Throwable $e) {
170+
throw new RestApiException('Failed to fetch service account access token: ' . $e->getMessage(), 500);
171+
}
66172
}
67173

68174
public static function createRetryMiddleware(
@@ -100,8 +206,16 @@ public function createRetryCallback(): callable
100206
?ResponseInterface $response = null,
101207
) use ($api) {
102208
if ($response && $response->getStatusCode() === 401) {
103-
$tokens = $api->refreshToken();
104-
return $request->withHeader('Authorization', 'Bearer ' . $tokens['access_token']);
209+
if ($api->authType === self::AUTH_TYPE_SERVICE_ACCOUNT) {
210+
// For Service Account, get new access token
211+
$api->serviceAccountAccessToken = null; // Clear cache
212+
$accessToken = $api->getServiceAccountAccessToken();
213+
return $request->withHeader('Authorization', 'Bearer ' . $accessToken);
214+
} else {
215+
// For OAuth, use refresh token
216+
$tokens = $api->refreshToken();
217+
return $request->withHeader('Authorization', 'Bearer ' . $tokens['access_token']);
218+
}
105219
}
106220
return $request;
107221
};
@@ -148,14 +262,27 @@ public function setCredentials(string $accessToken, string $refreshToken): void
148262

149263
public function getAccessToken(): string
150264
{
265+
if ($this->authType === self::AUTH_TYPE_SERVICE_ACCOUNT) {
266+
return $this->getServiceAccountAccessToken();
267+
}
268+
151269
return $this->accessToken;
152270
}
153271

154272
public function getRefreshToken(): array
155273
{
274+
if ($this->authType === self::AUTH_TYPE_SERVICE_ACCOUNT) {
275+
throw new RestApiException('Refresh token is not applicable for service account authentication', 400);
276+
}
277+
156278
return $this->refreshToken();
157279
}
158280

281+
public function getAuthType(): string
282+
{
283+
return $this->authType;
284+
}
285+
159286
public function setRefreshTokenCallback(callable $callback): void
160287
{
161288
$this->refreshTokenCallback = $callback;
@@ -260,13 +387,21 @@ public function request(
260387
throw new RestApiException('Wrong http method specified', 500);
261388
}
262389

263-
if ($this->refreshToken === null) {
264-
throw new RestApiException('Refresh token must be set', 400);
390+
// Validate authentication based on type
391+
if ($this->authType === self::AUTH_TYPE_OAUTH && $this->refreshToken === null) {
392+
throw new RestApiException('Refresh token must be set for OAuth authentication', 400);
393+
} elseif ($this->authType === self::AUTH_TYPE_SERVICE_ACCOUNT &&
394+
($this->serviceAccountConfig === null || $this->scopes === null || empty($this->scopes))
395+
) {
396+
throw new RestApiException(
397+
'Service account configuration and scopes must be set for service account authentication',
398+
400,
399+
);
265400
}
266401

267402
$headers = [
268403
'Accept' => 'application/json',
269-
'Authorization' => 'Bearer ' . $this->accessToken,
404+
'Authorization' => 'Bearer ' . $this->getAccessToken(),
270405
];
271406

272407
foreach ($addHeaders as $k => $v) {
@@ -306,11 +441,11 @@ protected function logRetryRequest(
306441
];
307442

308443
$this->logger->info(
309-
sprintf('Retrying request (%sx) - reason: %s', $retries, $response->getReasonPhrase()),
444+
sprintf('Retrying request (%dx) - reason: %s', $retries, $response->getReasonPhrase()),
310445
$context,
311446
);
312447
} else {
313-
$this->logger->info(sprintf('Retrying request (%sx)', $retries), $context);
448+
$this->logger->info(sprintf('Retrying request (%dx)', $retries), $context);
314449
}
315450
}
316451
}

0 commit comments

Comments
 (0)