diff --git a/.gitignore b/.gitignore index a1c524c33..b958dd074 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ composer.lock .cache .docs .gitmodules +.phpunit.result.cache # IntelliJ .idea diff --git a/src/CredentialSource/FileSource.php b/src/CredentialSource/FileSource.php new file mode 100644 index 000000000..e2afc6c58 --- /dev/null +++ b/src/CredentialSource/FileSource.php @@ -0,0 +1,75 @@ +file = $file; + + if ($format === 'json' && is_null($subjectTokenFieldName)) { + throw new InvalidArgumentException( + 'subject_token_field_name must be set when format is JSON' + ); + } + + $this->format = $format; + $this->subjectTokenFieldName = $subjectTokenFieldName; + } + + public function fetchSubjectToken(callable $httpHandler = null): string + { + $contents = file_get_contents($this->file); + if ($this->format === 'json') { + if (!$json = json_decode((string) $contents, true)) { + throw new UnexpectedValueException( + 'Unable to decode JSON file' + ); + } + if (!isset($json[$this->subjectTokenFieldName])) { + throw new UnexpectedValueException( + 'subject_token_field_name not found in JSON file' + ); + } + $contents = $json[$this->subjectTokenFieldName]; + } + + return $contents; + } +} diff --git a/src/CredentialSource/UrlSource.php b/src/CredentialSource/UrlSource.php new file mode 100644 index 000000000..0acb3c6ef --- /dev/null +++ b/src/CredentialSource/UrlSource.php @@ -0,0 +1,97 @@ + + */ + private ?array $headers; + + /** + * @param string $url The URL to fetch the subject token from. + * @param string $format The format of the token in the response. Can be null or "json". + * @param string $subjectTokenFieldName The name of the field containing the token in the response. This is required + * when format is "json". + * @param array $headers Request headers to send in with the request to the URL. + */ + public function __construct( + string $url, + string $format = null, + string $subjectTokenFieldName = null, + array $headers = null + ) { + $this->url = $url; + + if ($format === 'json' && is_null($subjectTokenFieldName)) { + throw new InvalidArgumentException( + 'subject_token_field_name must be set when format is JSON' + ); + } + + $this->format = $format; + $this->subjectTokenFieldName = $subjectTokenFieldName; + $this->headers = $headers; + } + + public function fetchSubjectToken(callable $httpHandler = null): string + { + if (is_null($httpHandler)) { + $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + } + + $request = new Request( + 'GET', + $this->url, + $this->headers ?: [] + ); + + $response = $httpHandler($request); + $body = (string) $response->getBody(); + if ($this->format === 'json') { + if (!$json = json_decode((string) $body, true)) { + throw new UnexpectedValueException( + 'Unable to decode JSON response' + ); + } + if (!isset($json[$this->subjectTokenFieldName])) { + throw new UnexpectedValueException( + 'subject_token_field_name not found in JSON file' + ); + } + $body = $json[$this->subjectTokenFieldName]; + } + + return $body; + } +} diff --git a/src/Credentials/ExternalAccountCredentials.php b/src/Credentials/ExternalAccountCredentials.php new file mode 100644 index 000000000..8461b276b --- /dev/null +++ b/src/Credentials/ExternalAccountCredentials.php @@ -0,0 +1,143 @@ + $jsonKey JSON credentials as an associative array. + */ + public function __construct( + $scope, + array $jsonKey + ) { + if (!array_key_exists('type', $jsonKey)) { + throw new InvalidArgumentException('json key is missing the type field'); + } + if ($jsonKey['type'] !== self::EXTERNAL_ACCOUNT_TYPE) { + throw new InvalidArgumentException(sprintf( + 'expected "%s" type but received "%s"', + self::EXTERNAL_ACCOUNT_TYPE, + $jsonKey['type'] + )); + } + + if (!array_key_exists('token_url', $jsonKey)) { + throw new InvalidArgumentException( + 'json key is missing the token_url field' + ); + } + + if (!array_key_exists('audience', $jsonKey)) { + throw new InvalidArgumentException( + 'json key is missing the audience field' + ); + } + + if (!array_key_exists('subject_token_type', $jsonKey)) { + throw new InvalidArgumentException( + 'json key is missing the subject_token_type field' + ); + } + + if (!array_key_exists('credential_source', $jsonKey)) { + throw new InvalidArgumentException( + 'json key is missing the credential_source field' + ); + } + + $this->auth = new OAuth2([ + 'tokenCredentialUri' => $jsonKey['token_url'], + 'audience' => $jsonKey['audience'], + 'scope' => $scope, + 'subjectTokenType' => $jsonKey['subject_token_type'], + 'subjectTokenFetcher' => self::buildCredentialSource($jsonKey), + ]); + } + + /** + * @param array $jsonKey + */ + private static function buildCredentialSource(array $jsonKey): ExternalAccountCredentialSourceInterface + { + $credentialSource = $jsonKey['credential_source']; + if (isset($credentialSource['file'])) { + return new FileSource( + $credentialSource['file'], + $credentialSource['format']['type'] ?? null, + $credentialSource['format']['subject_token_field_name'] ?? null + ); + } + + if (isset($credentialSource['url'])) { + return new UrlSource( + $credentialSource['url'], + $credentialSource['format']['type'] ?? null, + $credentialSource['format']['subject_token_field_name'] ?? null, + $credentialSource['headers'] ?? null, + ); + } + + throw new InvalidArgumentException('Unable to determine credential source from json key.'); + } + + /** + * @param callable $httpHandler + * + * @return array { + * A set of auth related metadata, containing the following + * + * @type string $access_token + * @type int $expires_in + * @type string $scope + * @type string $token_type + * @type string $id_token + * } + */ + public function fetchAuthToken(callable $httpHandler = null) + { + return $this->auth->fetchAuthToken($httpHandler); + } + + public function getCacheKey() + { + return $this->auth->getCacheKey(); + } + + public function getLastReceivedToken() + { + return $this->auth->getLastReceivedToken(); + } +} diff --git a/src/CredentialsLoader.php b/src/CredentialsLoader.php index ada8e759c..9e28701ed 100644 --- a/src/CredentialsLoader.php +++ b/src/CredentialsLoader.php @@ -17,6 +17,7 @@ namespace Google\Auth; +use Google\Auth\Credentials\ExternalAccountCredentials; use Google\Auth\Credentials\ImpersonatedServiceAccountCredentials; use Google\Auth\Credentials\InsecureCredentials; use Google\Auth\Credentials\ServiceAccountCredentials; @@ -32,6 +33,8 @@ abstract class CredentialsLoader implements FetchAuthTokenInterface, UpdateMetadataInterface { + use UpdateMetadataTrait; + const TOKEN_CREDENTIAL_URI = 'https://oauth2.googleapis.com/token'; const ENV_VAR = 'GOOGLE_APPLICATION_CREDENTIALS'; const QUOTA_PROJECT_ENV_VAR = 'GOOGLE_CLOUD_QUOTA_PROJECT'; @@ -122,7 +125,7 @@ public static function fromWellKnownFile() * user-defined scopes exist, expressed either as an Array or as a * space-delimited string. * - * @return ServiceAccountCredentials|UserRefreshCredentials|ImpersonatedServiceAccountCredentials + * @return ServiceAccountCredentials|UserRefreshCredentials|ImpersonatedServiceAccountCredentials|ExternalAccountCredentials */ public static function makeCredentials( $scope, @@ -148,6 +151,11 @@ public static function makeCredentials( return new ImpersonatedServiceAccountCredentials($anyScope, $jsonKey); } + if ($jsonKey['type'] == 'external_account') { + $anyScope = $scope ?: $defaultScope; + return new ExternalAccountCredentials($anyScope, $jsonKey); + } + throw new \InvalidArgumentException('invalid value in the type field'); } @@ -190,44 +198,6 @@ public static function makeInsecureCredentials() return new InsecureCredentials(); } - /** - * export a callback function which updates runtime metadata. - * - * @return callable updateMetadata function - * @deprecated - */ - public function getUpdateMetadataFunc() - { - return [$this, 'updateMetadata']; - } - - /** - * Updates metadata with the authorization token. - * - * @param array $metadata metadata hashmap - * @param string $authUri optional auth uri - * @param callable $httpHandler callback which delivers psr7 request - * @return array updated metadata hashmap - */ - public function updateMetadata( - $metadata, - $authUri = null, - callable $httpHandler = null - ) { - if (isset($metadata[self::AUTH_METADATA_KEY])) { - // Auth metadata has already been set - return $metadata; - } - $result = $this->fetchAuthToken($httpHandler); - $metadata_copy = $metadata; - if (isset($result['access_token'])) { - $metadata_copy[self::AUTH_METADATA_KEY] = ['Bearer ' . $result['access_token']]; - } elseif (isset($result['id_token'])) { - $metadata_copy[self::AUTH_METADATA_KEY] = ['Bearer ' . $result['id_token']]; - } - return $metadata_copy; - } - /** * Fetch a quota project from the environment variable * GOOGLE_CLOUD_QUOTA_PROJECT. Return null if diff --git a/src/ExternalAccountCredentialSourceInterface.php b/src/ExternalAccountCredentialSourceInterface.php new file mode 100644 index 000000000..b4d00f8b4 --- /dev/null +++ b/src/ExternalAccountCredentialSourceInterface.php @@ -0,0 +1,23 @@ + $config Configuration array */ public function __construct(array $config) @@ -368,6 +433,11 @@ public function __construct(array $config) 'scope' => null, 'additionalClaims' => [], 'codeVerifier' => null, + 'resource' => null, + 'subjectTokenFetcher' => null, + 'subjectTokenType' => null, + 'actorToken' => null, + 'actorTokenType' => null, ], $config); $this->setAuthorizationUri($opts['authorizationUri']); @@ -389,6 +459,14 @@ public function __construct(array $config) $this->setExtensionParams($opts['extensionParams']); $this->setAdditionalClaims($opts['additionalClaims']); $this->setCodeVerifier($opts['codeVerifier']); + + // for STS + $this->resource = $opts['resource']; + $this->subjectTokenFetcher = $opts['subjectTokenFetcher']; + $this->subjectTokenType = $opts['subjectTokenType']; + $this->actorToken = $opts['actorToken']; + $this->actorTokenType = $opts['actorTokenType']; + $this->updateToken($opts); } @@ -493,9 +571,10 @@ public function toJwt(array $config = []) /** * Generates a request for token credentials. * + * @param callable $httpHandler callback which delivers psr7 request * @return RequestInterface the authorization Url. */ - public function generateCredentialsRequest() + public function generateCredentialsRequest(callable $httpHandler = null) { $uri = $this->getTokenCredentialUri(); if (is_null($uri)) { @@ -525,6 +604,19 @@ public function generateCredentialsRequest() case self::JWT_URN: $params['assertion'] = $this->toJwt(); break; + case self::STS_URN: + $token = $this->subjectTokenFetcher->fetchSubjectToken($httpHandler); + $params['subject_token'] = $token; + $params['subject_token_type'] = $this->subjectTokenType; + $params += array_filter([ + 'resource' => $this->resource, + 'audience' => $this->audience, + 'scope' => $this->getScope(), + 'requested_token_type' => self::STS_REQUESTED_TOKEN_TYPE, + 'actor_token' => $this->actorToken, + 'actor_token_type' => $this->actorTokenType, + ]); + break; default: if (!is_null($this->getRedirectUri())) { # Grant type was supposed to be 'authorization_code', as there @@ -563,7 +655,7 @@ public function fetchAuthToken(callable $httpHandler = null) $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient()); } - $response = $httpHandler($this->generateCredentialsRequest()); + $response = $httpHandler($this->generateCredentialsRequest($httpHandler)); $credentials = $this->parseTokenResponse($response); $this->updateToken($credentials); if (isset($credentials['scope'])) { @@ -685,6 +777,12 @@ public function updateToken(array $config) if (array_key_exists('refresh_token', $opts)) { $this->setRefreshToken($opts['refresh_token']); } + + // Required for STS response. An identifier for the representation of + // the issued security token. + if (array_key_exists('issued_token_type', $opts)) { + $this->issuedTokenType = $opts['issued_token_type']; + } } /** @@ -965,6 +1063,10 @@ public function getGrantType() return self::JWT_URN; } + if (!is_null($this->subjectTokenFetcher) && !is_null($this->subjectTokenType)) { + return self::STS_URN; + } + return null; } @@ -1492,6 +1594,16 @@ public function getAdditionalClaims() return $this->additionalClaims; } + /** + * Gets the additional claims to be included in the JWT token. + * + * @return ?string + */ + public function getIssuedTokenType() + { + return $this->issuedTokenType; + } + /** * The expiration of the last received token. * diff --git a/src/UpdateMetadataTrait.php b/src/UpdateMetadataTrait.php new file mode 100644 index 000000000..fd33e0dca --- /dev/null +++ b/src/UpdateMetadataTrait.php @@ -0,0 +1,66 @@ + $metadata metadata hashmap + * @param string $authUri optional auth uri + * @param callable $httpHandler callback which delivers psr7 request + * @return array updated metadata hashmap + */ + public function updateMetadata( + $metadata, + $authUri = null, + callable $httpHandler = null + ) { + if (isset($metadata[self::AUTH_METADATA_KEY])) { + // Auth metadata has already been set + return $metadata; + } + $result = $this->fetchAuthToken($httpHandler); + $metadata_copy = $metadata; + if (isset($result['access_token'])) { + $metadata_copy[self::AUTH_METADATA_KEY] = ['Bearer ' . $result['access_token']]; + } elseif (isset($result['id_token'])) { + $metadata_copy[self::AUTH_METADATA_KEY] = ['Bearer ' . $result['id_token']]; + } + return $metadata_copy; + } +} diff --git a/tests/ApplicationDefaultCredentialsTest.php b/tests/ApplicationDefaultCredentialsTest.php index d49b726f1..e3d7a8dcc 100644 --- a/tests/ApplicationDefaultCredentialsTest.php +++ b/tests/ApplicationDefaultCredentialsTest.php @@ -19,9 +19,11 @@ use DomainException; use Google\Auth\ApplicationDefaultCredentials; +use Google\Auth\Credentials\ExternalAccountCredentials; use Google\Auth\Credentials\GCECredentials; use Google\Auth\Credentials\ServiceAccountCredentials; use Google\Auth\CredentialsLoader; +use Google\Auth\CredentialSource; use Google\Auth\GCECache; use GuzzleHttp\Psr7; use GuzzleHttp\Psr7\Response; @@ -748,4 +750,36 @@ public function testAppEngineFlexibleIdToken() $creds ); } + + /** + * @dataProvider provideExternalAccountCredentials + */ + public function testExternalAccountCredentials(string $jsonFile, string $expectedCredSource) + { + putenv(sprintf('GOOGLE_APPLICATION_CREDENTIALS=%s/fixtures6/%s', __DIR__, $jsonFile)); + + $creds = ApplicationDefaultCredentials::getCredentials('a_scope'); + + $this->assertInstanceOf(ExternalAccountCredentials::class, $creds); + + $credsReflection = new \ReflectionClass($creds); + $credsProp = $credsReflection->getProperty('auth'); + $credsProp->setAccessible(true); + + $oauth = $credsProp->getValue($creds); + $oauthReflection = new \ReflectionClass($oauth); + $oauthProp = $oauthReflection->getProperty('subjectTokenFetcher'); + $oauthProp->setAccessible(true); + + $subjectTokenFetcher = $oauthProp->getValue($oauth); + $this->assertInstanceOf($expectedCredSource, $subjectTokenFetcher); + } + + public function provideExternalAccountCredentials() + { + return [ + ['file_credentials.json', CredentialSource\FileSource::class], + ['url_credentials.json', CredentialSource\UrlSource::class], + ]; + } } diff --git a/tests/CredentialSource/FileSourceTest.php b/tests/CredentialSource/FileSourceTest.php new file mode 100644 index 000000000..e2c79bde7 --- /dev/null +++ b/tests/CredentialSource/FileSourceTest.php @@ -0,0 +1,89 @@ +fetchSubjectToken(); + $this->assertEquals($expectedToken, $subjectToken); + } + + public function provideFetchSubjectToken() + { + $file1 = tempnam(sys_get_temp_dir(), 'test1'); + file_put_contents($file1, 'abc'); + + + $file2 = tempnam(sys_get_temp_dir(), 'test2'); + file_put_contents($file2, json_encode(['token' => 'def'])); + + return [ + [$file1, 'abc'], + [$file2, 'def', 'json', 'token'] + ]; + } + + public function testFormatJsonWithNoSubjectTokenFieldNameThrowsException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('subject_token_field_name must be set when format is JSON'); + + new FileSource('file', 'json'); + } + + public function testFormatJsonWithInvalidSubjectTokenFieldNameThrowsException() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('subject_token_field_name not found in JSON file'); + + $file1 = tempnam(sys_get_temp_dir(), 'test'); + file_put_contents($file1, json_encode(['good_field_name' => 'abc'])); + + (new FileSource($file1, 'json', 'bad_field_name')) + ->fetchSubjectToken(); + } + + public function testFormatJsonWithInvalidJsonFileThrowsException() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Unable to decode JSON file'); + + $file1 = tempnam(sys_get_temp_dir(), 'test'); + file_put_contents($file1, '{not-json}'); + + (new FileSource($file1, 'json', 'bad_field_name')) + ->fetchSubjectToken(); + } +} diff --git a/tests/CredentialSource/UrlSourceTest.php b/tests/CredentialSource/UrlSourceTest.php new file mode 100644 index 000000000..5a07cc5e1 --- /dev/null +++ b/tests/CredentialSource/UrlSourceTest.php @@ -0,0 +1,144 @@ +assertEquals('GET', $request->getMethod()); + $this->assertEquals('test.url', (string) $request->getUri()); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn($responseBody); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + return $response->reveal(); + }; + + $source = new UrlSource('test.url', $format, $subjectTokenFieldName); + $subjectToken = $source->fetchSubjectToken($handler); + $this->assertEquals($expectedToken, $subjectToken); + } + + public function provideFetchSubjectToken() + { + return [ + ['abc', 'abc', null], + [json_encode(['token' => 'def']), 'def', 'json', 'token'] + ]; + } + + public function testHeaders() + { + $handler = function (RequestInterface $request): ResponseInterface { + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('test.url', (string) $request->getUri()); + $this->assertEquals('abc', (string) $request->getHeaderLine('custom-header-1')); + $this->assertEquals('def', (string) $request->getHeaderLine('custom-header-2')); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn('xyz'); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body); + + return $response->reveal(); + }; + + $headers = [ + 'custom-header-1' => 'abc', + 'custom-header-2' => 'def', + ]; + + $source = new UrlSource('test.url', null, null, $headers); + $subjectToken = $source->fetchSubjectToken($handler); + $this->assertEquals('xyz', $subjectToken); + } + + public function testFormatJsonWithNoSubjectTokenFieldNameThrowsException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('subject_token_field_name must be set when format is JSON'); + + new UrlSource('test.url', 'json'); + } + + public function testFormatJsonWithInvalidSubjectTokenFieldNameThrowsException() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('subject_token_field_name not found in JSON file'); + + $handler = function (RequestInterface $request): ResponseInterface { + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('test.url', (string) $request->getUri()); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn(json_encode(['good_field_name' => 'abc'])); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + return $response->reveal(); + }; + + (new UrlSource('test.url', 'json', 'bad_field_name')) + ->fetchSubjectToken($handler); + } + + public function testFormatJsonWithInvalidJsonResponseThrowsException() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Unable to decode JSON response'); + + $handler = function (RequestInterface $request): ResponseInterface { + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('test.url', (string) $request->getUri()); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn('{not-json}'); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + return $response->reveal(); + }; + + (new UrlSource('test.url', 'json', 'bad_field_name')) + ->fetchSubjectToken($handler); + } +} diff --git a/tests/Credentials/ExternalAccountCredentialsTest.php b/tests/Credentials/ExternalAccountCredentialsTest.php new file mode 100644 index 000000000..f5dcc30f7 --- /dev/null +++ b/tests/Credentials/ExternalAccountCredentialsTest.php @@ -0,0 +1,131 @@ + 'external_account', + 'token_url' => '', + 'audience' => '', + 'subject_token_type' => '', + 'credential_source' => $credentialSource, + ]; + + $credsReflection = new \ReflectionClass(ExternalAccountCredentials::class); + $credsProp = $credsReflection->getProperty('auth'); + $credsProp->setAccessible(true); + + $creds = new ExternalAccountCredentials('a-scope', $jsonCreds); + $oauth = $credsProp->getValue($creds); + + $oauthReflection = new \ReflectionClass(OAuth2::class); + $oauthProp = $oauthReflection->getProperty('subjectTokenFetcher'); + $oauthProp->setAccessible(true); + $subjectTokenFetcher = $oauthProp->getValue($oauth); + + $this->assertInstanceOf($expectedSourceClass, $subjectTokenFetcher); + } + + public function provideCredentialSourceFromCredentials() + { + return [ + [ + ['file' => 'path/to/credsfile.json'], + FileSource::class + ], + [ + ['file' => 'path/to/credsfile.json', 'format' => ['type' => 'json', 'subject_token_field_name' => 'token']], + FileSource::class + ], + [ + ['url' => 'https://test.com'], + UrlSource::class + ], + [ + ['url' => 'https://test.com', 'format' => ['type' => 'json', 'subject_token_field_name' => 'token']], + UrlSource::class + ], + [ + ['url' => 'https://test.com', 'format' => ['type' => 'json', 'subject_token_field_name' => 'token', 'headers' => []]], + UrlSource::class + ], + ]; + } + + /** + * @dataProvider provideInvalidCredentialsJson + */ + public function testInvalidCredentialsJsonThrowsException(array $json, string $exceptionMessage) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($exceptionMessage); + + new ExternalAccountCredentials('a-scope', $json); + } + + public function provideInvalidCredentialsJson() + { + return [ + [ + [], + 'json key is missing the type field' + ], + [ + ['type' => 'foo'], + 'expected "external_account" type but received "foo"' + ], + [ + ['type' => 'external_account'], + 'json key is missing the token_url field' + ], + [ + ['type' => 'external_account', 'token_url' => ''], + 'json key is missing the audience field' + ], + [ + ['type' => 'external_account', 'token_url' => '', 'audience' => ''], + 'json key is missing the subject_token_type field' + ], + [ + ['type' => 'external_account', 'token_url' => '', 'audience' => '', 'subject_token_type' => ''], + 'json key is missing the credential_source field' + ], + [ + ['type' => 'external_account', 'token_url' => '', 'audience' => '', 'subject_token_type' => '', 'credential_source' => []], + 'Unable to determine credential source from json key' + ], + ]; + } +} diff --git a/tests/OAuth2Test.php b/tests/OAuth2Test.php index 1a7ccdd64..8de3f35b9 100644 --- a/tests/OAuth2Test.php +++ b/tests/OAuth2Test.php @@ -20,12 +20,14 @@ use DomainException; use Firebase\JWT\JWT; use Firebase\JWT\Key; +use Google\Auth\ExternalAccountCredentialSourceInterface; use Google\Auth\OAuth2; use GuzzleHttp\Psr7\Query; use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\Utils; use InvalidArgumentException; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use UnexpectedValueException; class OAuth2Test extends TestCase @@ -1252,3 +1254,70 @@ public function testShouldReturnAValidIdToken() $this->assertEquals($origIdToken['aud'], $roundTrip->aud); } } + +class OAuth2StsTest extends TestCase +{ + use ProphecyTrait; + + private $publicKey; + private $privateKey; + private $stsMinimal = [ + 'tokenCredentialUri' => 'https://tokens_r_us/test', + 'subjectTokenType' => 'urn:ietf:params:aws:token-type:aws4_request', + ]; + + public function testStsGrantType() + { + $credentialSource = $this->prophesize(ExternalAccountCredentialSourceInterface::class); + $o = new OAuth2($this->stsMinimal + ['subjectTokenFetcher' => $credentialSource->reveal()]); + $this->assertEquals(OAuth2::STS_URN, $o->getGrantType()); + } + + public function testStsCredentialsRequestMinimal() + { + $credentialSource = $this->prophesize(ExternalAccountCredentialSourceInterface::class); + $credentialSource->fetchSubjectToken(null) + ->shouldBeCalledOnce() + ->willReturn('xyz'); + $o = new OAuth2($this->stsMinimal + ['subjectTokenFetcher' => $credentialSource->reveal()]); + $request = $o->generateCredentialsRequest(); + $this->assertEquals('POST', $request->getMethod()); + $this->assertEquals($this->stsMinimal['tokenCredentialUri'], (string) $request->getUri()); + parse_str((string)$request->getBody(), $requestParams); + $this->assertCount(4, $requestParams); + $this->assertEquals(OAuth2::STS_URN, $requestParams['grant_type']); + $this->assertEquals('xyz', $requestParams['subject_token']); + $this->assertEquals($this->stsMinimal['subjectTokenType'], $requestParams['subject_token_type']); + } + + public function testStsCredentialsRequestFull() + { + $credentialSource = $this->prophesize(ExternalAccountCredentialSourceInterface::class); + $credentialSource->fetchSubjectToken(null) + ->shouldBeCalledOnce() + ->willReturn('xyz'); + $stsMinimal = $this->stsMinimal + [ + 'subjectTokenFetcher' => $credentialSource->reveal(), + 'resource' => 'abc', + 'scope' => ['scope1', 'scope2'], + 'audience' => 'def', + 'actorToken' => '123', + 'actorTokenType' => 'urn:ietf:params:oauth:token-type:access_token', + ]; + $o = new OAuth2($stsMinimal); + $request = $o->generateCredentialsRequest(); + $this->assertEquals('POST', $request->getMethod()); + $this->assertEquals($this->stsMinimal['tokenCredentialUri'], (string) $request->getUri()); + parse_str((string)$request->getBody(), $requestParams); + + $this->assertCount(9, $requestParams); + $this->assertEquals(OAuth2::STS_URN, $requestParams['grant_type']); + $this->assertEquals('xyz', $requestParams['subject_token']); + $this->assertEquals($stsMinimal['subjectTokenType'], $requestParams['subject_token_type']); + $this->assertEquals($stsMinimal['resource'], $requestParams['resource']); + $this->assertEquals('scope1 scope2', $requestParams['scope']); + $this->assertEquals($stsMinimal['audience'], $requestParams['audience']); + $this->assertEquals($stsMinimal['actorToken'], $requestParams['actor_token']); + $this->assertEquals($stsMinimal['actorTokenType'], $requestParams['actor_token_type']); + } +} diff --git a/tests/fixtures6/file_credentials.json b/tests/fixtures6/file_credentials.json new file mode 100644 index 000000000..55fd6bf39 --- /dev/null +++ b/tests/fixtures6/file_credentials.json @@ -0,0 +1,9 @@ +{ + "type": "external_account", + "audience": "some_audience", + "subject_token_type": "access_token", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": { + "file": "some_file.txt" + } + } diff --git a/tests/fixtures6/url_credentials.json b/tests/fixtures6/url_credentials.json new file mode 100644 index 000000000..1a7681d8a --- /dev/null +++ b/tests/fixtures6/url_credentials.json @@ -0,0 +1,9 @@ +{ + "type": "external_account", + "audience": "some_audience", + "subject_token_type": "access_token", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": { + "url": "https://some_url.io" + } + }