Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for Impersonated Service Account Credentials #580

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
22d07e2
WIP
bshaffer Oct 1, 2024
203722d
open up jsonKey to any credential class
bshaffer Oct 1, 2024
ba37442
fix typos
bshaffer Oct 1, 2024
f5a1057
support ID token impersonation
bshaffer Oct 1, 2024
3b38b22
fix FetchAuthTokenInterface typehint
bshaffer Oct 1, 2024
15b6c33
add support for impersonating id tokens
bshaffer Oct 1, 2024
acd55a3
fix phpstan and Obervability test
bshaffer Oct 3, 2024
522f605
fix phpstan errors, add headers parameter for other credential types
bshaffer Oct 3, 2024
c50541e
fix cs and tests
bshaffer Oct 3, 2024
fa156fa
fix phpstan
bshaffer Oct 4, 2024
f9a2835
Merge branch 'main' into add-impersonated-service-account-credentials
bshaffer Oct 4, 2024
20f2c56
add tests
bshaffer Oct 4, 2024
bdf24f8
Merge branch 'add-impersonated-service-account-credentials' of github…
bshaffer Oct 4, 2024
244724f
add impersonate id token metric test
bshaffer Oct 5, 2024
66b099b
fix cs and phpstan
bshaffer Oct 5, 2024
58b0ed6
add tests for arbitrary credentials
bshaffer Oct 5, 2024
19ae608
add test for Impersonating ExternalAccountCreds
bshaffer Oct 5, 2024
a70d236
ensure complete test coverage, fix cs
bshaffer Oct 5, 2024
c63443c
change scope to auth/iam, use constant
bshaffer Oct 7, 2024
b09e8c3
Merge branch 'main' into add-impersonated-service-account-credentials
bshaffer Nov 5, 2024
df63099
fix test
bshaffer Nov 5, 2024
54b2284
fix cs
bshaffer Nov 5, 2024
dca23f0
fix case
bshaffer Nov 5, 2024
837eba5
Merge branch 'main' into add-impersonated-service-account-credentials
bshaffer Nov 13, 2024
76f48e6
Update src/FetchAuthTokenInterface.php
bshaffer Nov 13, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/ApplicationDefaultCredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use DomainException;
use Google\Auth\Credentials\AppIdentityCredentials;
use Google\Auth\Credentials\GCECredentials;
use Google\Auth\Credentials\ImpersonatedServiceAccountCredentials;
use Google\Auth\Credentials\ServiceAccountCredentials;
use Google\Auth\Credentials\UserRefreshCredentials;
use Google\Auth\HttpHandler\HttpClientCache;
Expand Down Expand Up @@ -301,6 +302,7 @@ public static function getIdTokenCredentials(

$creds = match ($jsonKey['type']) {
'authorized_user' => new UserRefreshCredentials(null, $jsonKey, $targetAudience),
'impersonated_service_account' => new ImpersonatedServiceAccountCredentials(null, $jsonKey, $targetAudience),
'service_account' => new ServiceAccountCredentials(null, $jsonKey, null, $targetAudience),
default => throw new InvalidArgumentException('invalid value in the type field')
};
Expand Down
54 changes: 42 additions & 12 deletions src/Credentials/ImpersonatedServiceAccountCredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements
use IamSignerTrait;

private const CRED_TYPE = 'imp';
private const IAM_SCOPE = 'https://www.googleapis.com/auth/iam';

/**
* @var string
Expand Down Expand Up @@ -71,10 +72,12 @@ class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements
* @type int $lifetime The lifetime of the impersonated credentials
* @type string[] $delegates The delegates to impersonate
* }
* @param string|null $targetAudience The audience to request an ID token.
*/
public function __construct(
$scope,
$jsonKey
$jsonKey,
private ?string $targetAudience = null
) {
if (is_string($jsonKey)) {
if (!file_exists($jsonKey)) {
Expand All @@ -93,10 +96,23 @@ public function __construct(
if (!array_key_exists('source_credentials', $jsonKey)) {
throw new LogicException('json key is missing the source_credentials field');
}
if ($scope && $targetAudience) {
throw new InvalidArgumentException(
'Scope and targetAudience cannot both be supplied'
);
}
if (is_array($jsonKey['source_credentials'])) {
if (!array_key_exists('type', $jsonKey['source_credentials'])) {
throw new InvalidArgumentException('json key source credentials are missing the type field');
}
if (
$targetAudience !== null
&& $jsonKey['source_credentials']['type'] === 'service_account'
) {
// Service account tokens MUST request a scope, and as this token is only used to impersonate
// an ID token, the narrowest scope we can request is `iam`.
$scope = self::IAM_SCOPE;
}
$jsonKey['source_credentials'] = CredentialsLoader::makeCredentials($scope, $jsonKey['source_credentials']);
}

Expand Down Expand Up @@ -171,13 +187,19 @@ public function fetchAuthToken(?callable $httpHandler = null)
'Content-Type' => 'application/json',
'Cache-Control' => 'no-store',
'Authorization' => sprintf('Bearer %s', $authToken['access_token'] ?? $authToken['id_token']),
], 'at');

$body = [
'scope' => $this->targetScope,
'delegates' => $this->delegates,
'lifetime' => sprintf('%ss', $this->lifetime),
];
], $this->isIdTokenRequest() ? 'it' : 'at');

$body = match ($this->isIdTokenRequest()) {
true => [
'audience' => $this->targetAudience,
'includeEmail' => true,
],
false => [
'scope' => $this->targetScope,
'delegates' => $this->delegates,
'lifetime' => sprintf('%ss', $this->lifetime),
]
};

$request = new Request(
'POST',
Expand All @@ -189,10 +211,13 @@ public function fetchAuthToken(?callable $httpHandler = null)
$response = $httpHandler($request);
$body = json_decode((string) $response->getBody(), true);

return [
'access_token' => $body['accessToken'],
'expires_at' => strtotime($body['expireTime']),
];
return match ($this->isIdTokenRequest()) {
true => ['id_token' => $body['token']],
false => [
'access_token' => $body['accessToken'],
'expires_at' => strtotime($body['expireTime']),
]
};
}

/**
Expand Down Expand Up @@ -220,4 +245,9 @@ protected function getCredType(): string
{
return self::CRED_TYPE;
}

private function isIdTokenRequest(): bool
{
return !is_null($this->targetAudience);
}
}
1 change: 1 addition & 0 deletions src/FetchAuthTokenInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ interface FetchAuthTokenInterface
* Fetches the auth tokens based on the current state.
*
* @param callable|null $httpHandler callback which delivers psr7 request
*
bshaffer marked this conversation as resolved.
Show resolved Hide resolved
* @return array<mixed> a hash of auth tokens
*/
public function fetchAuthToken(?callable $httpHandler = null);
Expand Down
7 changes: 7 additions & 0 deletions tests/ApplicationDefaultCredentialsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,13 @@ public function testGetIdTokenCredentialsFailsIfNotOnGceAndNoDefaultFileFound()
);
}

public function testGetIdTokenCredentialsWithImpersonatedServiceAccountCredentials()
{
putenv('HOME=' . __DIR__ . '/fixtures5');
$creds = ApplicationDefaultCredentials::getIdTokenCredentials('[email protected]');
$this->assertInstanceOf(ImpersonatedServiceAccountCredentials::class, $creds);
}

public function testGetIdTokenCredentialsWithCacheOptions()
{
$keyFile = __DIR__ . '/fixtures' . '/private.json';
Expand Down
134 changes: 134 additions & 0 deletions tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
use Google\Auth\Credentials\ServiceAccountCredentials;
use Google\Auth\Credentials\UserRefreshCredentials;
use Google\Auth\FetchAuthTokenInterface;
use Google\Auth\Middleware\AuthTokenMiddleware;
use Google\Auth\OAuth2;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use LogicException;
use PHPUnit\Framework\TestCase;
Expand Down Expand Up @@ -133,6 +135,44 @@ public function testGetAccessTokenWithServiceAccountAndUserRefreshCredentials($j
$this->assertEquals(2, $requestCount);
}

/**
* Test ID token impersonation for Service Account and User Refresh Credentials.
*
* @dataProvider provideAuthTokenJson
*/
public function testGetIdTokenWithServiceAccountAndUserRefreshCredentials($json, $grantType)
{
$requestCount = 0;
// getting an id token will take two requests
$httpHandler = function (RequestInterface $request) use (&$requestCount, $json, $grantType) {
if (++$requestCount == 1) {
// the call to swap the refresh token for an access token
$this->assertEquals(UserRefreshCredentials::TOKEN_CREDENTIAL_URI, (string) $request->getUri());
parse_str((string) $request->getBody(), $result);
$this->assertEquals($grantType, $result['grant_type']);
} elseif ($requestCount == 2) {
// the call to swap the access token for an id token
$this->assertEquals($json['service_account_impersonation_url'], (string) $request->getUri());
$this->assertEquals(self::TARGET_AUDIENCE, json_decode($request->getBody(), true)['audience'] ?? '');
$this->assertEquals('Bearer test-access-token', $request->getHeader('authorization')[0] ?? null);
}

return new Response(
200,
['Content-Type' => 'application/json'],
json_encode(match ($requestCount) {
1 => ['access_token' => 'test-access-token'],
2 => ['token' => 'test-impersonated-id-token']
})
);
};

$creds = new ImpersonatedServiceAccountCredentials(null, $json, self::TARGET_AUDIENCE);
$token = $creds->fetchAuthToken($httpHandler);
$this->assertEquals('test-impersonated-id-token', $token['id_token']);
$this->assertEquals(2, $requestCount);
}

public function provideAuthTokenJson()
{
return [
Expand Down Expand Up @@ -180,6 +220,72 @@ public function testGetAccessTokenWithExternalAccountCredentials()
$this->assertEquals(3, $requestCount);
}

/**
* Test ID token impersonation for Exernal Account Credentials.
*/
public function testGetIdTokenWithExternalAccountCredentials()
{
$json = self::EXTERNAL_ACCOUNT_TO_SERVICE_ACCOUNT_JSON;
$httpHandler = function (RequestInterface $request) use (&$requestCount, $json) {
if (++$requestCount == 1) {
// the call to swap the refresh token for an access token
$this->assertEquals(
$json['source_credentials']['credential_source']['url'],
(string) $request->getUri()
);
} elseif ($requestCount == 2) {
$this->assertEquals($json['source_credentials']['token_url'], (string) $request->getUri());
} elseif ($requestCount == 3) {
// the call to swap the access token for an id token
$this->assertEquals($json['service_account_impersonation_url'], (string) $request->getUri());
$this->assertEquals(self::TARGET_AUDIENCE, json_decode($request->getBody(), true)['audience'] ?? '');
$this->assertEquals('Bearer test-access-token', $request->getHeader('authorization')[0] ?? null);
}

return new Response(
200,
['Content-Type' => 'application/json'],
json_encode(match ($requestCount) {
1 => ['access_token' => 'test-access-token'],
2 => ['access_token' => 'test-access-token'],
3 => ['token' => 'test-impersonated-id-token']
})
);
};

$creds = new ImpersonatedServiceAccountCredentials(null, $json, self::TARGET_AUDIENCE);
$token = $creds->fetchAuthToken($httpHandler);
$this->assertEquals('test-impersonated-id-token', $token['id_token']);
$this->assertEquals(3, $requestCount);
}

/**
* Test ID token impersonation for an arbitrary credential fetcher.
*/
public function testGetIdTokenWithArbitraryCredentials()
{
$httpHandler = function (RequestInterface $request) {
$this->assertEquals('https://some/url', (string) $request->getUri());
$this->assertEquals('Bearer test-access-token', $request->getHeader('authorization')[0] ?? null);
return new Response(200, [], json_encode(['token' => 'test-impersonated-id-token']));
};

$credentials = $this->prophesize(FetchAuthTokenInterface::class);
$credentials->fetchAuthToken($httpHandler, Argument::type('array'))
->shouldBeCalledOnce()
->willReturn(['access_token' => 'test-access-token']);

$json = [
'type' => 'impersonated_service_account',
'service_account_impersonation_url' => 'https://some/url',
'source_credentials' => $credentials->reveal(),
];
$creds = new ImpersonatedServiceAccountCredentials(null, $json, self::TARGET_AUDIENCE);

$token = $creds->fetchAuthToken($httpHandler);
$this->assertEquals('test-impersonated-id-token', $token['id_token']);
}

/**
* Test access token impersonation for an arbitrary credential fetcher.
*/
Expand Down Expand Up @@ -211,6 +317,34 @@ public function testGetAccessTokenWithArbitraryCredentials()
$this->assertEquals('test-impersonated-access-token', $token['access_token']);
}

public function testIdTokenWithAuthTokenMiddleware()
{
$targetAudience = 'test-target-audience';
$credentials = new ImpersonatedServiceAccountCredentials(null, self::USER_TO_SERVICE_ACCOUNT_JSON, $targetAudience);

// this handler is for the middleware constructor, which will pass it to the ISAC to fetch tokens
$httpHandler = getHandler([
new Response(200, ['Content-Type' => 'application/json'], '{"access_token":"this.is.an.access.token"}'),
new Response(200, ['Content-Type' => 'application/json'], '{"token":"this.is.an.id.token"}'),
]);
$middleware = new AuthTokenMiddleware($credentials, $httpHandler);

// this handler is the actual handler that makes the authenticated request
$requestCount = 0;
$httpHandler = function (RequestInterface $request) use (&$requestCount) {
$requestCount++;
$this->assertTrue($request->hasHeader('authorization'));
$this->assertEquals('Bearer this.is.an.id.token', $request->getHeader('authorization')[0] ?? null);
};

$middleware($httpHandler)(
new Request('GET', 'https://www.google.com'),
['auth' => 'google_auth']
);

$this->assertEquals(1, $requestCount);
}

// User Refresh to Service Account Impersonation JSON Credentials
private const USER_TO_SERVICE_ACCOUNT_JSON = [
'type' => 'impersonated_service_account',
Expand Down
14 changes: 14 additions & 0 deletions tests/ObservabilityMetricsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,20 @@ public function testImpersonatedServiceAccountCredentials()
$this->assertUpdateMetadata($impersonatedCred, $handler, 'imp', $handlerCalled);
}

public function testImpersonatedServiceAccountCredentialsWithIdTokens()
{
$keyFile = __DIR__ . '/fixtures5/.config/gcloud/application_default_credentials.json';
$handlerCalled = false;
$responseFromIam = json_encode(['token' => '1/abdef1234567890']);
$handler = getHandler([
$this->getExpectedRequest('imp', 'auth-request-type/at', $handlerCalled, $this->jsonTokens),
$this->getExpectedRequest('imp', 'auth-request-type/it', $handlerCalled, $responseFromIam),
]);

$impersonatedCred = new ImpersonatedServiceAccountCredentials(null, $keyFile, 'test-target-audience');
$this->assertUpdateMetadata($impersonatedCred, $handler, 'imp', $handlerCalled);
}

/**
* UserRefreshCredentials haven't enabled identity token support hence
* they don't have 'auth-request-type/it' observability metric header check.
Expand Down