Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
63 changes: 62 additions & 1 deletion src/CredentialSource/AwsNativeSource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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
*
Expand Down
216 changes: 216 additions & 0 deletions tests/CredentialSource/AwsNativeSourceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
29 changes: 29 additions & 0 deletions tests/fixtures/local-ecs-test/local-ecs-mock-server.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php
header('Content-Type: application/json');

$scenario = $_GET['scenario'] ?? 'success';

if ($scenario === 'server_error') {
http_response_code(500);
echo json_encode(['error' => '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);
Loading
Loading