From 52ba4430d0586fcf0c5820932cd6d60808493d05 Mon Sep 17 00:00:00 2001 From: Charlotte Yun Date: Mon, 6 Jul 2026 18:34:57 -0700 Subject: [PATCH 1/2] feat: add workload identity federation support for AWS ECS tasks (#496) --- src/CredentialSource/AwsNativeSource.php | 63 ++++- .../CredentialSource/AwsNativeSourceTest.php | 216 ++++++++++++++++++ 2 files changed, 278 insertions(+), 1 deletion(-) diff --git a/src/CredentialSource/AwsNativeSource.php b/src/CredentialSource/AwsNativeSource.php index c4113f709b..a22fd6bb14 100644 --- a/src/CredentialSource/AwsNativeSource.php +++ b/src/CredentialSource/AwsNativeSource.php @@ -28,6 +28,7 @@ class AwsNativeSource implements ExternalAccountCredentialSourceInterface { private const CRED_VERIFICATION_QUERY = 'Action=GetCallerIdentity&Version=2011-06-15'; + private const ECS_CONTAINER_METADATA_URL = 'http://169.254.170.2'; private string $audience; private string $regionalCredVerificationUrl; @@ -74,7 +75,10 @@ public function fetchSubjectToken(?callable $httpHandler = null): string ]; } - if (!$signingVars = self::getSigningVarsFromEnv()) { + $signingVars = self::getSigningVarsFromEnv() + ?? self::getSigningVarsFromEcs($httpHandler); + + if (!$signingVars) { if (!$this->securityCredentialsUrl) { throw new \LogicException('Unable to get credentials from ENV, and no security credentials URL provided'); } @@ -308,6 +312,63 @@ public static function getSigningVarsFromUrl( ]; } + /** + * @internal + * + * @param callable $httpHandler + * @return array{string, string, ?string}|null + */ + public static function getSigningVarsFromEcs(callable $httpHandler): ?array + { + // Load the environment variables defined by AWS for the ECS/EKS container metadata. + $ecsContainerCredentialsRelativeUri = getenv('AWS_CONTAINER_CREDENTIALS_RELATIVE_URI'); + $ecsContainerCredentialsFullUri = getenv('AWS_CONTAINER_CREDENTIALS_FULL_URI'); + $ecsContainerAuthorizationToken = getenv('AWS_CONTAINER_AUTHORIZATION_TOKEN'); + $ecsContainerAuthorizationTokenFile = getenv('AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE'); + + $credentialsUrl = ''; + // The full URI takes precedence over the relative URI if both are defined. + if ($ecsContainerCredentialsFullUri) { + $credentialsUrl = $ecsContainerCredentialsFullUri; + } elseif ($ecsContainerCredentialsRelativeUri) { + // The relative URI is appended to the default ECS Task Metadata Endpoint. + $credentialsUrl = self::ECS_CONTAINER_METADATA_URL . $ecsContainerCredentialsRelativeUri; + } else { + // Not running in an ECS environment, or metadata is not enabled. + return null; + } + + $headers = []; + // The authorization token file takes precedence over the direct token variable. + if ($ecsContainerAuthorizationTokenFile) { + if (is_readable($ecsContainerAuthorizationTokenFile)) { + $headers['Authorization'] = trim((string) file_get_contents($ecsContainerAuthorizationTokenFile)); + } else { + throw new \RuntimeException( + sprintf('Token file %s is not readable', $ecsContainerAuthorizationTokenFile) + ); + } + } elseif ($ecsContainerAuthorizationToken) { + $headers['Authorization'] = $ecsContainerAuthorizationToken; + } + + // Fetch the temporary AWS credentials from the resolved metadata endpoint. + $credsRequest = new Request('GET', $credentialsUrl, $headers); + $credsResponse = $httpHandler($credsRequest); + $awsCreds = json_decode((string) $credsResponse->getBody(), true); + + // Ensure the response has the minimum required credential fields. + if (!is_array($awsCreds) || !isset($awsCreds['AccessKeyId']) || !isset($awsCreds['SecretAccessKey'])) { + throw new \UnexpectedValueException('Invalid or missing ECS credentials in response'); + } + + return [ + $awsCreds['AccessKeyId'], + $awsCreds['SecretAccessKey'], + $awsCreds['Token'] ?? null, + ]; + } + /** * @internal * diff --git a/tests/CredentialSource/AwsNativeSourceTest.php b/tests/CredentialSource/AwsNativeSourceTest.php index fc44f2ebd9..7800e360b4 100644 --- a/tests/CredentialSource/AwsNativeSourceTest.php +++ b/tests/CredentialSource/AwsNativeSourceTest.php @@ -258,6 +258,222 @@ public function testFetchSubjectTokenWithoutSecurityCredentialsUrlOrEnvThrowsExc $aws->fetchSubjectToken($httpHandler); } + /** @runInSeparateProcess */ + public function testGetSigningVarsFromEcsWithRelativeUri() + { + putenv('AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/v2/credentials/test'); + + $httpHandler = function (RequestInterface $request): ResponseInterface { + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals( + 'http://169.254.170.2/v2/credentials/test', + (string) $request->getUri() + ); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn(json_encode([ + 'AccessKeyId' => 'expected-access-key-id', + 'SecretAccessKey' => 'expected-secret-access-key', + 'Token' => 'expected-token', + ])); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + return $response->reveal(); + }; + + $signingVars = AwsNativeSource::getSigningVarsFromEcs($httpHandler); + + $this->assertEquals('expected-access-key-id', $signingVars[0]); + $this->assertEquals('expected-secret-access-key', $signingVars[1]); + $this->assertEquals('expected-token', $signingVars[2]); + } + + /** @runInSeparateProcess */ + public function testGetSigningVarsFromEcsWithFullUri() + { + putenv('AWS_CONTAINER_CREDENTIALS_FULL_URI=http://localhost:8080/credentials'); + + $httpHandler = function (RequestInterface $request): ResponseInterface { + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals( + 'http://localhost:8080/credentials', + (string) $request->getUri() + ); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn(json_encode([ + 'AccessKeyId' => 'expected-access-key-id', + 'SecretAccessKey' => 'expected-secret-access-key', + ])); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + return $response->reveal(); + }; + + $signingVars = AwsNativeSource::getSigningVarsFromEcs($httpHandler); + + $this->assertEquals('expected-access-key-id', $signingVars[0]); + $this->assertEquals('expected-secret-access-key', $signingVars[1]); + $this->assertNull($signingVars[2]); + } + + /** @runInSeparateProcess */ + public function testGetSigningVarsFromEcsWithAuthToken() + { + putenv('AWS_CONTAINER_CREDENTIALS_FULL_URI=http://localhost:8080/credentials'); + putenv('AWS_CONTAINER_AUTHORIZATION_TOKEN=auth-token-123'); + + $httpHandler = function (RequestInterface $request): ResponseInterface { + $this->assertEquals('auth-token-123', $request->getHeaderLine('Authorization')); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn(json_encode([ + 'AccessKeyId' => 'expected-access-key-id', + 'SecretAccessKey' => 'expected-secret-access-key', + ])); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + return $response->reveal(); + }; + + AwsNativeSource::getSigningVarsFromEcs($httpHandler); + } + + /** @runInSeparateProcess */ + public function testGetSigningVarsFromEcsWithAuthTokenFile() + { + $tokenFile = tempnam(sys_get_temp_dir(), 'aws_token'); + file_put_contents($tokenFile, 'auth-token-file-123'); + + putenv('AWS_CONTAINER_CREDENTIALS_FULL_URI=http://localhost:8080/credentials'); + putenv('AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE=' . $tokenFile); + putenv('AWS_CONTAINER_AUTHORIZATION_TOKEN=auth-token-123'); // File should take precedence + + $httpHandler = function (RequestInterface $request): ResponseInterface { + $this->assertEquals('auth-token-file-123', $request->getHeaderLine('Authorization')); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn(json_encode([ + 'AccessKeyId' => 'expected-access-key-id', + 'SecretAccessKey' => 'expected-secret-access-key', + ])); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + return $response->reveal(); + }; + + try { + AwsNativeSource::getSigningVarsFromEcs($httpHandler); + } finally { + if (file_exists($tokenFile)) { + unlink($tokenFile); + } + } + } + + /** @runInSeparateProcess */ + public function testGetSigningVarsFromEcsWithUnreadableAuthTokenFile() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Token file /does/not/exist/token is not readable'); + + putenv('AWS_CONTAINER_CREDENTIALS_FULL_URI=http://localhost:8080/credentials'); + putenv('AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE=/does/not/exist/token'); + + $httpHandler = function (RequestInterface $request): ResponseInterface { + $this->fail('HTTP handler should not be called'); + }; + + AwsNativeSource::getSigningVarsFromEcs($httpHandler); + } + + /** @runInSeparateProcess */ + public function testGetSigningVarsFromEcsThrowsUnexpectedValueExceptionOnInvalidResponse() + { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid or missing ECS credentials in response'); + + putenv('AWS_CONTAINER_CREDENTIALS_FULL_URI=http://localhost:8080/credentials'); + + $httpHandler = function (RequestInterface $request): ResponseInterface { + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn(json_encode(['invalid' => 'response'])); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + return $response->reveal(); + }; + + AwsNativeSource::getSigningVarsFromEcs($httpHandler); + } + + /** @runInSeparateProcess */ + public function testGetSigningVarsFromEcsReturnsNullWhenUrisNotSet() + { + // No environment variables set + $httpHandler = function (RequestInterface $request): ResponseInterface { + $this->fail('HTTP handler should not be called'); + }; + + $signingVars = AwsNativeSource::getSigningVarsFromEcs($httpHandler); + + $this->assertNull($signingVars); + } + + /** + * @runInSeparateProcess + */ + public function testFetchSubjectTokenFromEcs() + { + $aws = new AwsNativeSource( + $this->audience, + $this->regionUrl, + $this->regionalCredVerificationUrl, + ); + + putenv('AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/v2/credentials/test'); + + // Mock response from AWS ECS Metadata Server + $awsTokenBody = $this->prophesize(StreamInterface::class); + $awsTokenBody->__toString()->willReturn(json_encode([ + 'AccessKeyId' => 'expected-access-key-id', + 'SecretAccessKey' => 'expected-secret-access-key', + 'Token' => 'expected-token', + ])); + $awsTokenResponse = $this->prophesize(ResponseInterface::class); + $awsTokenResponse->getBody()->willReturn($awsTokenBody->reveal()); + + // Mock response from Region URL + $regionBody = $this->prophesize(StreamInterface::class); + $regionBody->__toString()->willReturn('us-east-2b'); + $regionResponse = $this->prophesize(ResponseInterface::class); + $regionResponse->getBody()->willReturn($regionBody->reveal()); + + $requestCount = 0; + $httpHandler = function (RequestInterface $request) use ( + $awsTokenResponse, + $regionResponse, + &$requestCount + ): ResponseInterface { + $requestCount++; + switch ($requestCount) { + case 1: return $awsTokenResponse->reveal(); + case 2: return $regionResponse->reveal(); + } + throw new \Exception('Unexpected request'); + }; + + $subjectToken = $aws->fetchSubjectToken($httpHandler); + $unserializedToken = json_decode(urldecode($subjectToken), true); + $this->assertArrayHasKey('headers', $unserializedToken); + $this->assertArrayHasKey('method', $unserializedToken); + $this->assertArrayHasKey('url', $unserializedToken); + } + /** * @runInSeparateProcess */ From 9863020dd05d1f30ef9f8f54791897893b99ab4d Mon Sep 17 00:00:00 2001 From: Charlotte Yun Date: Tue, 7 Jul 2026 17:44:39 -0700 Subject: [PATCH 2/2] Add GitHub Action and thorough local mock testing suite for ECS --- .github/workflows/tests.yml | 5 + .../local-ecs-test/local-ecs-mock-server.php | 29 +++++ .../local-ecs-test/local-ecs-test.php | 120 ++++++++++++++++++ .../local-ecs-test/run-local-ecs-test.sh | 14 ++ 4 files changed, 168 insertions(+) create mode 100644 tests/fixtures/local-ecs-test/local-ecs-mock-server.php create mode 100644 tests/fixtures/local-ecs-test/local-ecs-test.php create mode 100755 tests/fixtures/local-ecs-test/run-local-ecs-test.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cafec3081b..c4561219b9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,6 +29,9 @@ jobs: command: composer install - name: Run Script run: vendor/bin/phpunit + - name: Run ECS Mock Tests + if: matrix.os == 'ubuntu-latest' + run: bash tests/fixtures/local-ecs-test/run-local-ecs-test.sh test_lowest: runs-on: ubuntu-latest name: Test Prefer Lowest @@ -46,3 +49,5 @@ jobs: command: composer update --prefer-lowest - name: Run Script run: vendor/bin/phpunit + - name: Run ECS Mock Tests + run: bash tests/fixtures/local-ecs-test/run-local-ecs-test.sh diff --git a/tests/fixtures/local-ecs-test/local-ecs-mock-server.php b/tests/fixtures/local-ecs-test/local-ecs-mock-server.php new file mode 100644 index 0000000000..59efa122eb --- /dev/null +++ b/tests/fixtures/local-ecs-test/local-ecs-mock-server.php @@ -0,0 +1,29 @@ + 'Internal Server Error']); + exit; +} + +if ($scenario === 'invalid_json') { + echo '{ malformed JSON! '; + exit; +} + +$response = [ + 'Token' => 'MOCK_SESSION_TOKEN_789', + 'Expiration' => date('Y-m-d\TH:i:s\Z', strtotime('+1 hour')) +]; + +if ($scenario === 'success') { + $response['AccessKeyId'] = 'MOCK_ACCESS_KEY_123'; + $response['SecretAccessKey'] = 'MOCK_SECRET_KEY_456'; +} elseif ($scenario === 'missing_fields') { + // Only return Token and Expiration, simulating a bad response without keys. +} + +echo json_encode($response); diff --git a/tests/fixtures/local-ecs-test/local-ecs-test.php b/tests/fixtures/local-ecs-test/local-ecs-test.php new file mode 100644 index 0000000000..98bbd12a60 --- /dev/null +++ b/tests/fixtures/local-ecs-test/local-ecs-test.php @@ -0,0 +1,120 @@ + true]); +$httpHandler = function ($request) use ($client) { + return $client->send($request); +}; + +$testsPassed = 0; +$testsFailed = 0; + +function runTestCase($name, $setupEnv, $assertion) +{ + global $httpHandler, $testsPassed, $testsFailed; + + echo "Running Test: $name... "; + + // Clear potentially polluting env vars before setup + putenv('AWS_CONTAINER_CREDENTIALS_FULL_URI'); + putenv('AWS_CONTAINER_CREDENTIALS_RELATIVE_URI'); + putenv('AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE'); + + $setupEnv(); + + try { + $result = AwsNativeSource::getSigningVarsFromEcs($httpHandler); + $assertion($result, null); + } catch (\Exception $e) { + $assertion(null, $e); + } +} + +// 1. Standard Success +runTestCase('Success - Valid Credentials', function () { + putenv('AWS_CONTAINER_CREDENTIALS_FULL_URI=http://127.0.0.1:8080/?scenario=success'); +}, function ($result, $exception) use (&$testsPassed, &$testsFailed) { + if ($result && $result[0] === 'MOCK_ACCESS_KEY_123' && $result[1] === 'MOCK_SECRET_KEY_456') { + echo "✅ PASS\n"; + $testsPassed++; + } else { + echo "❌ FAIL (Expected valid credentials array)\n"; + $testsFailed++; + } +}); + +// 2. Invalid Metadata Response (JSON Error) +runTestCase('Error - Invalid JSON Response', function () { + putenv('AWS_CONTAINER_CREDENTIALS_FULL_URI=http://127.0.0.1:8080/?scenario=invalid_json'); +}, function ($result, $exception) use (&$testsPassed, &$testsFailed) { + if ($exception instanceof \UnexpectedValueException && strpos($exception->getMessage(), 'Invalid or missing ECS credentials') !== false) { + echo "✅ PASS\n"; + $testsPassed++; + } else { + echo "❌ FAIL (Expected UnexpectedValueException)\n"; + $testsFailed++; + } +}); + +// 3. Missing Required Fields +runTestCase('Error - Missing Fields in JSON', function () { + putenv('AWS_CONTAINER_CREDENTIALS_FULL_URI=http://127.0.0.1:8080/?scenario=missing_fields'); +}, function ($result, $exception) use (&$testsPassed, &$testsFailed) { + if ($exception instanceof \UnexpectedValueException && strpos($exception->getMessage(), 'Invalid or missing ECS credentials') !== false) { + echo "✅ PASS\n"; + $testsPassed++; + } else { + echo "❌ FAIL (Expected UnexpectedValueException)\n"; + $testsFailed++; + } +}); + +// 4. Server Error (500) +runTestCase('Error - HTTP 500 Server Error', function () { + putenv('AWS_CONTAINER_CREDENTIALS_FULL_URI=http://127.0.0.1:8080/?scenario=server_error'); +}, function ($result, $exception) use (&$testsPassed, &$testsFailed) { + if ($exception instanceof \GuzzleHttp\Exception\ServerException) { + echo "✅ PASS\n"; + $testsPassed++; + } else { + echo "❌ FAIL (Expected Guzzle ServerException)\n"; + $testsFailed++; + } +}); + +// 5. Unreadable Token File +runTestCase('Error - Unreadable Token File', function () { + putenv('AWS_CONTAINER_CREDENTIALS_FULL_URI=http://127.0.0.1:8080/?scenario=success'); + putenv('AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE=/path/to/nowhere/that/does/not/exist.txt'); +}, function ($result, $exception) use (&$testsPassed, &$testsFailed) { + if ($exception instanceof \RuntimeException && strpos($exception->getMessage(), 'is not readable') !== false) { + echo "✅ PASS\n"; + $testsPassed++; + } else { + echo "❌ FAIL (Expected RuntimeException for unreadable file)\n"; + $testsFailed++; + } +}); + +// 6. Bailout (No Environment Variables) +runTestCase('Success - Bailout (No URIs Set)', function () { + // We already clear the env vars in runTestCase. +}, function ($result, $exception) use (&$testsPassed, &$testsFailed) { + if ($result === null && $exception === null) { + echo "✅ PASS\n"; + $testsPassed++; + } else { + echo "❌ FAIL (Expected null return)\n"; + $testsFailed++; + } +}); + +echo "\nTest Suite Complete. Passed: $testsPassed, Failed: $testsFailed\n"; +if ($testsFailed > 0) { + exit(1); +} diff --git a/tests/fixtures/local-ecs-test/run-local-ecs-test.sh b/tests/fixtures/local-ecs-test/run-local-ecs-test.sh new file mode 100755 index 0000000000..1421f0c37a --- /dev/null +++ b/tests/fixtures/local-ecs-test/run-local-ecs-test.sh @@ -0,0 +1,14 @@ +#!/bin/bash +cd "$(dirname "$0")" + +echo "Starting mock PHP server on port 8080..." +php -S 127.0.0.1:8080 local-ecs-mock-server.php > /dev/null 2>&1 & +SERVER_PID=$! + +sleep 1 +echo "Running test client..." +php local-ecs-test.php + +echo "Cleaning up..." +kill $SERVER_PID +echo "Done!"