[e3d4e0a] | 1 | <?php
|
---|
| 2 | /*
|
---|
| 3 | * Copyright 2023 Google Inc.
|
---|
| 4 | *
|
---|
| 5 | * Licensed under the Apache License, Version 2.0 (the "License");
|
---|
| 6 | * you may not use this file except in compliance with the License.
|
---|
| 7 | * You may obtain a copy of the License at
|
---|
| 8 | *
|
---|
| 9 | * http://www.apache.org/licenses/LICENSE-2.0
|
---|
| 10 | *
|
---|
| 11 | * Unless required by applicable law or agreed to in writing, software
|
---|
| 12 | * distributed under the License is distributed on an "AS IS" BASIS,
|
---|
| 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
---|
| 14 | * See the License for the specific language governing permissions and
|
---|
| 15 | * limitations under the License.
|
---|
| 16 | */
|
---|
| 17 |
|
---|
| 18 | namespace Google\Auth\CredentialSource;
|
---|
| 19 |
|
---|
| 20 | use Google\Auth\ExternalAccountCredentialSourceInterface;
|
---|
| 21 | use Google\Auth\HttpHandler\HttpClientCache;
|
---|
| 22 | use Google\Auth\HttpHandler\HttpHandlerFactory;
|
---|
| 23 | use GuzzleHttp\Psr7\Request;
|
---|
| 24 |
|
---|
| 25 | /**
|
---|
| 26 | * Authenticates requests using AWS credentials.
|
---|
| 27 | */
|
---|
| 28 | class AwsNativeSource implements ExternalAccountCredentialSourceInterface
|
---|
| 29 | {
|
---|
| 30 | private const CRED_VERIFICATION_QUERY = 'Action=GetCallerIdentity&Version=2011-06-15';
|
---|
| 31 |
|
---|
| 32 | private string $audience;
|
---|
| 33 | private string $regionalCredVerificationUrl;
|
---|
| 34 | private ?string $regionUrl;
|
---|
| 35 | private ?string $securityCredentialsUrl;
|
---|
| 36 | private ?string $imdsv2SessionTokenUrl;
|
---|
| 37 |
|
---|
| 38 | /**
|
---|
| 39 | * @param string $audience The audience for the credential.
|
---|
| 40 | * @param string $regionalCredVerificationUrl The regional AWS GetCallerIdentity action URL used to determine the
|
---|
| 41 | * AWS account ID and its roles. This is not called by this library, but
|
---|
| 42 | * is sent in the subject token to be called by the STS token server.
|
---|
| 43 | * @param string|null $regionUrl This URL should be used to determine the current AWS region needed for the signed
|
---|
| 44 | * request construction.
|
---|
| 45 | * @param string|null $securityCredentialsUrl The AWS metadata server URL used to retrieve the access key, secret
|
---|
| 46 | * key and security token needed to sign the GetCallerIdentity request.
|
---|
| 47 | * @param string|null $imdsv2SessionTokenUrl Presence of this URL enforces the auth libraries to fetch a Session
|
---|
| 48 | * Token from AWS. This field is required for EC2 instances using IMDSv2.
|
---|
| 49 | */
|
---|
| 50 | public function __construct(
|
---|
| 51 | string $audience,
|
---|
| 52 | string $regionalCredVerificationUrl,
|
---|
| 53 | ?string $regionUrl = null,
|
---|
| 54 | ?string $securityCredentialsUrl = null,
|
---|
| 55 | ?string $imdsv2SessionTokenUrl = null
|
---|
| 56 | ) {
|
---|
| 57 | $this->audience = $audience;
|
---|
| 58 | $this->regionalCredVerificationUrl = $regionalCredVerificationUrl;
|
---|
| 59 | $this->regionUrl = $regionUrl;
|
---|
| 60 | $this->securityCredentialsUrl = $securityCredentialsUrl;
|
---|
| 61 | $this->imdsv2SessionTokenUrl = $imdsv2SessionTokenUrl;
|
---|
| 62 | }
|
---|
| 63 |
|
---|
| 64 | public function fetchSubjectToken(?callable $httpHandler = null): string
|
---|
| 65 | {
|
---|
| 66 | if (is_null($httpHandler)) {
|
---|
| 67 | $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient());
|
---|
| 68 | }
|
---|
| 69 |
|
---|
| 70 | $headers = [];
|
---|
| 71 | if ($this->imdsv2SessionTokenUrl) {
|
---|
| 72 | $headers = [
|
---|
| 73 | 'X-aws-ec2-metadata-token' => self::getImdsV2SessionToken($this->imdsv2SessionTokenUrl, $httpHandler)
|
---|
| 74 | ];
|
---|
| 75 | }
|
---|
| 76 |
|
---|
| 77 | if (!$signingVars = self::getSigningVarsFromEnv()) {
|
---|
| 78 | if (!$this->securityCredentialsUrl) {
|
---|
| 79 | throw new \LogicException('Unable to get credentials from ENV, and no security credentials URL provided');
|
---|
| 80 | }
|
---|
| 81 | $signingVars = self::getSigningVarsFromUrl(
|
---|
| 82 | $httpHandler,
|
---|
| 83 | $this->securityCredentialsUrl,
|
---|
| 84 | self::getRoleName($httpHandler, $this->securityCredentialsUrl, $headers),
|
---|
| 85 | $headers
|
---|
| 86 | );
|
---|
| 87 | }
|
---|
| 88 |
|
---|
| 89 | if (!$region = self::getRegionFromEnv()) {
|
---|
| 90 | if (!$this->regionUrl) {
|
---|
| 91 | throw new \LogicException('Unable to get region from ENV, and no region URL provided');
|
---|
| 92 | }
|
---|
| 93 | $region = self::getRegionFromUrl($httpHandler, $this->regionUrl, $headers);
|
---|
| 94 | }
|
---|
| 95 | $url = str_replace('{region}', $region, $this->regionalCredVerificationUrl);
|
---|
| 96 | $host = parse_url($url)['host'] ?? '';
|
---|
| 97 |
|
---|
| 98 | // From here we use the signing vars to create the signed request to receive a token
|
---|
| 99 | [$accessKeyId, $secretAccessKey, $securityToken] = $signingVars;
|
---|
| 100 | $headers = self::getSignedRequestHeaders($region, $host, $accessKeyId, $secretAccessKey, $securityToken);
|
---|
| 101 |
|
---|
| 102 | // Inject x-goog-cloud-target-resource into header
|
---|
| 103 | $headers['x-goog-cloud-target-resource'] = $this->audience;
|
---|
| 104 |
|
---|
| 105 | // Format headers as they're expected in the subject token
|
---|
| 106 | $formattedHeaders = array_map(
|
---|
| 107 | fn ($k, $v) => ['key' => $k, 'value' => $v],
|
---|
| 108 | array_keys($headers),
|
---|
| 109 | $headers,
|
---|
| 110 | );
|
---|
| 111 |
|
---|
| 112 | $request = [
|
---|
| 113 | 'headers' => $formattedHeaders,
|
---|
| 114 | 'method' => 'POST',
|
---|
| 115 | 'url' => $url,
|
---|
| 116 | ];
|
---|
| 117 |
|
---|
| 118 | return urlencode(json_encode($request) ?: '');
|
---|
| 119 | }
|
---|
| 120 |
|
---|
| 121 | /**
|
---|
| 122 | * @internal
|
---|
| 123 | */
|
---|
| 124 | public static function getImdsV2SessionToken(string $imdsV2Url, callable $httpHandler): string
|
---|
| 125 | {
|
---|
| 126 | $headers = [
|
---|
| 127 | 'X-aws-ec2-metadata-token-ttl-seconds' => '21600'
|
---|
| 128 | ];
|
---|
| 129 | $request = new Request(
|
---|
| 130 | 'PUT',
|
---|
| 131 | $imdsV2Url,
|
---|
| 132 | $headers
|
---|
| 133 | );
|
---|
| 134 |
|
---|
| 135 | $response = $httpHandler($request);
|
---|
| 136 | return (string) $response->getBody();
|
---|
| 137 | }
|
---|
| 138 |
|
---|
| 139 | /**
|
---|
| 140 | * @see http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
|
---|
| 141 | *
|
---|
| 142 | * @internal
|
---|
| 143 | *
|
---|
| 144 | * @return array<string, string>
|
---|
| 145 | */
|
---|
| 146 | public static function getSignedRequestHeaders(
|
---|
| 147 | string $region,
|
---|
| 148 | string $host,
|
---|
| 149 | string $accessKeyId,
|
---|
| 150 | string $secretAccessKey,
|
---|
| 151 | ?string $securityToken
|
---|
| 152 | ): array {
|
---|
| 153 | $service = 'sts';
|
---|
| 154 |
|
---|
| 155 | # Create a date for headers and the credential string in ISO-8601 format
|
---|
| 156 | $amzdate = gmdate('Ymd\THis\Z');
|
---|
| 157 | $datestamp = gmdate('Ymd'); # Date w/o time, used in credential scope
|
---|
| 158 |
|
---|
| 159 | # Create the canonical headers and signed headers. Header names
|
---|
| 160 | # must be trimmed and lowercase, and sorted in code point order from
|
---|
| 161 | # low to high. Note that there is a trailing \n.
|
---|
| 162 | $canonicalHeaders = sprintf("host:%s\nx-amz-date:%s\n", $host, $amzdate);
|
---|
| 163 | if ($securityToken) {
|
---|
| 164 | $canonicalHeaders .= sprintf("x-amz-security-token:%s\n", $securityToken);
|
---|
| 165 | }
|
---|
| 166 |
|
---|
| 167 | # Step 5: Create the list of signed headers. This lists the headers
|
---|
| 168 | # in the canonicalHeaders list, delimited with ";" and in alpha order.
|
---|
| 169 | # Note: The request can include any headers; $canonicalHeaders and
|
---|
| 170 | # $signedHeaders lists those that you want to be included in the
|
---|
| 171 | # hash of the request. "Host" and "x-amz-date" are always required.
|
---|
| 172 | $signedHeaders = 'host;x-amz-date';
|
---|
| 173 | if ($securityToken) {
|
---|
| 174 | $signedHeaders .= ';x-amz-security-token';
|
---|
| 175 | }
|
---|
| 176 |
|
---|
| 177 | # Step 6: Create payload hash (hash of the request body content). For GET
|
---|
| 178 | # requests, the payload is an empty string ("").
|
---|
| 179 | $payloadHash = hash('sha256', '');
|
---|
| 180 |
|
---|
| 181 | # Step 7: Combine elements to create canonical request
|
---|
| 182 | $canonicalRequest = implode("\n", [
|
---|
| 183 | 'POST', // method
|
---|
| 184 | '/', // canonical URL
|
---|
| 185 | self::CRED_VERIFICATION_QUERY, // query string
|
---|
| 186 | $canonicalHeaders,
|
---|
| 187 | $signedHeaders,
|
---|
| 188 | $payloadHash
|
---|
| 189 | ]);
|
---|
| 190 |
|
---|
| 191 | # ************* TASK 2: CREATE THE STRING TO SIGN*************
|
---|
| 192 | # Match the algorithm to the hashing algorithm you use, either SHA-1 or
|
---|
| 193 | # SHA-256 (recommended)
|
---|
| 194 | $algorithm = 'AWS4-HMAC-SHA256';
|
---|
| 195 | $scope = implode('/', [$datestamp, $region, $service, 'aws4_request']);
|
---|
| 196 | $stringToSign = implode("\n", [$algorithm, $amzdate, $scope, hash('sha256', $canonicalRequest)]);
|
---|
| 197 |
|
---|
| 198 | # ************* TASK 3: CALCULATE THE SIGNATURE *************
|
---|
| 199 | # Create the signing key using the function defined above.
|
---|
| 200 | // (done above)
|
---|
| 201 | $signingKey = self::getSignatureKey($secretAccessKey, $datestamp, $region, $service);
|
---|
| 202 |
|
---|
| 203 | # Sign the string_to_sign using the signing_key
|
---|
| 204 | $signature = bin2hex(self::hmacSign($signingKey, $stringToSign));
|
---|
| 205 |
|
---|
| 206 | # ************* TASK 4: ADD SIGNING INFORMATION TO THE REQUEST *************
|
---|
| 207 | # The signing information can be either in a query string value or in
|
---|
| 208 | # a header named Authorization. This code shows how to use a header.
|
---|
| 209 | # Create authorization header and add to request headers
|
---|
| 210 | $authorizationHeader = sprintf(
|
---|
| 211 | '%s Credential=%s/%s, SignedHeaders=%s, Signature=%s',
|
---|
| 212 | $algorithm,
|
---|
| 213 | $accessKeyId,
|
---|
| 214 | $scope,
|
---|
| 215 | $signedHeaders,
|
---|
| 216 | $signature
|
---|
| 217 | );
|
---|
| 218 |
|
---|
| 219 | # The request can include any headers, but MUST include "host", "x-amz-date",
|
---|
| 220 | # and (for this scenario) "Authorization". "host" and "x-amz-date" must
|
---|
| 221 | # be included in the canonical_headers and signed_headers, as noted
|
---|
| 222 | # earlier. Order here is not significant.
|
---|
| 223 | $headers = [
|
---|
| 224 | 'host' => $host,
|
---|
| 225 | 'x-amz-date' => $amzdate,
|
---|
| 226 | 'Authorization' => $authorizationHeader,
|
---|
| 227 | ];
|
---|
| 228 | if ($securityToken) {
|
---|
| 229 | $headers['x-amz-security-token'] = $securityToken;
|
---|
| 230 | }
|
---|
| 231 |
|
---|
| 232 | return $headers;
|
---|
| 233 | }
|
---|
| 234 |
|
---|
| 235 | /**
|
---|
| 236 | * @internal
|
---|
| 237 | */
|
---|
| 238 | public static function getRegionFromEnv(): ?string
|
---|
| 239 | {
|
---|
| 240 | $region = getenv('AWS_REGION');
|
---|
| 241 | if (empty($region)) {
|
---|
| 242 | $region = getenv('AWS_DEFAULT_REGION');
|
---|
| 243 | }
|
---|
| 244 | return $region ?: null;
|
---|
| 245 | }
|
---|
| 246 |
|
---|
| 247 | /**
|
---|
| 248 | * @internal
|
---|
| 249 | *
|
---|
| 250 | * @param callable $httpHandler
|
---|
| 251 | * @param string $regionUrl
|
---|
| 252 | * @param array<string, string|string[]> $headers Request headers to send in with the request.
|
---|
| 253 | */
|
---|
| 254 | public static function getRegionFromUrl(callable $httpHandler, string $regionUrl, array $headers): string
|
---|
| 255 | {
|
---|
| 256 | // get the region/zone from the region URL
|
---|
| 257 | $regionRequest = new Request('GET', $regionUrl, $headers);
|
---|
| 258 | $regionResponse = $httpHandler($regionRequest);
|
---|
| 259 |
|
---|
| 260 | // Remove last character. For example, if us-east-2b is returned,
|
---|
| 261 | // the region would be us-east-2.
|
---|
| 262 | return substr((string) $regionResponse->getBody(), 0, -1);
|
---|
| 263 | }
|
---|
| 264 |
|
---|
| 265 | /**
|
---|
| 266 | * @internal
|
---|
| 267 | *
|
---|
| 268 | * @param callable $httpHandler
|
---|
| 269 | * @param string $securityCredentialsUrl
|
---|
| 270 | * @param array<string, string|string[]> $headers Request headers to send in with the request.
|
---|
| 271 | */
|
---|
| 272 | public static function getRoleName(callable $httpHandler, string $securityCredentialsUrl, array $headers): string
|
---|
| 273 | {
|
---|
| 274 | // Get the AWS role name
|
---|
| 275 | $roleRequest = new Request('GET', $securityCredentialsUrl, $headers);
|
---|
| 276 | $roleResponse = $httpHandler($roleRequest);
|
---|
| 277 | $roleName = (string) $roleResponse->getBody();
|
---|
| 278 |
|
---|
| 279 | return $roleName;
|
---|
| 280 | }
|
---|
| 281 |
|
---|
| 282 | /**
|
---|
| 283 | * @internal
|
---|
| 284 | *
|
---|
| 285 | * @param callable $httpHandler
|
---|
| 286 | * @param string $securityCredentialsUrl
|
---|
| 287 | * @param array<string, string|string[]> $headers Request headers to send in with the request.
|
---|
| 288 | * @return array{string, string, ?string}
|
---|
| 289 | */
|
---|
| 290 | public static function getSigningVarsFromUrl(
|
---|
| 291 | callable $httpHandler,
|
---|
| 292 | string $securityCredentialsUrl,
|
---|
| 293 | string $roleName,
|
---|
| 294 | array $headers
|
---|
| 295 | ): array {
|
---|
| 296 | // Get the AWS credentials
|
---|
| 297 | $credsRequest = new Request(
|
---|
| 298 | 'GET',
|
---|
| 299 | $securityCredentialsUrl . '/' . $roleName,
|
---|
| 300 | $headers
|
---|
| 301 | );
|
---|
| 302 | $credsResponse = $httpHandler($credsRequest);
|
---|
| 303 | $awsCreds = json_decode((string) $credsResponse->getBody(), true);
|
---|
| 304 | return [
|
---|
| 305 | $awsCreds['AccessKeyId'], // accessKeyId
|
---|
| 306 | $awsCreds['SecretAccessKey'], // secretAccessKey
|
---|
| 307 | $awsCreds['Token'], // token
|
---|
| 308 | ];
|
---|
| 309 | }
|
---|
| 310 |
|
---|
| 311 | /**
|
---|
| 312 | * @internal
|
---|
| 313 | *
|
---|
| 314 | * @return array{string, string, ?string}
|
---|
| 315 | */
|
---|
| 316 | public static function getSigningVarsFromEnv(): ?array
|
---|
| 317 | {
|
---|
| 318 | $accessKeyId = getenv('AWS_ACCESS_KEY_ID');
|
---|
| 319 | $secretAccessKey = getenv('AWS_SECRET_ACCESS_KEY');
|
---|
| 320 | if ($accessKeyId && $secretAccessKey) {
|
---|
| 321 | return [
|
---|
| 322 | $accessKeyId,
|
---|
| 323 | $secretAccessKey,
|
---|
| 324 | getenv('AWS_SESSION_TOKEN') ?: null, // session token (can be null)
|
---|
| 325 | ];
|
---|
| 326 | }
|
---|
| 327 |
|
---|
| 328 | return null;
|
---|
| 329 | }
|
---|
| 330 |
|
---|
| 331 | /**
|
---|
| 332 | * Gets the unique key for caching
|
---|
| 333 | * For AwsNativeSource the values are:
|
---|
| 334 | * Imdsv2SessionTokenUrl.SecurityCredentialsUrl.RegionUrl.RegionalCredVerificationUrl
|
---|
| 335 | *
|
---|
| 336 | * @return string
|
---|
| 337 | */
|
---|
| 338 | public function getCacheKey(): string
|
---|
| 339 | {
|
---|
| 340 | return ($this->imdsv2SessionTokenUrl ?? '') .
|
---|
| 341 | '.' . ($this->securityCredentialsUrl ?? '') .
|
---|
| 342 | '.' . $this->regionUrl .
|
---|
| 343 | '.' . $this->regionalCredVerificationUrl;
|
---|
| 344 | }
|
---|
| 345 |
|
---|
| 346 | /**
|
---|
| 347 | * Return HMAC hash in binary string
|
---|
| 348 | */
|
---|
| 349 | private static function hmacSign(string $key, string $msg): string
|
---|
| 350 | {
|
---|
| 351 | return hash_hmac('sha256', self::utf8Encode($msg), $key, true);
|
---|
| 352 | }
|
---|
| 353 |
|
---|
| 354 | /**
|
---|
| 355 | * @TODO add a fallback when mbstring is not available
|
---|
| 356 | */
|
---|
| 357 | private static function utf8Encode(string $string): string
|
---|
| 358 | {
|
---|
| 359 | return mb_convert_encoding($string, 'UTF-8', 'ISO-8859-1');
|
---|
| 360 | }
|
---|
| 361 |
|
---|
| 362 | private static function getSignatureKey(
|
---|
| 363 | string $key,
|
---|
| 364 | string $dateStamp,
|
---|
| 365 | string $regionName,
|
---|
| 366 | string $serviceName
|
---|
| 367 | ): string {
|
---|
| 368 | $kDate = self::hmacSign(self::utf8Encode('AWS4' . $key), $dateStamp);
|
---|
| 369 | $kRegion = self::hmacSign($kDate, $regionName);
|
---|
| 370 | $kService = self::hmacSign($kRegion, $serviceName);
|
---|
| 371 | $kSigning = self::hmacSign($kService, 'aws4_request');
|
---|
| 372 |
|
---|
| 373 | return $kSigning;
|
---|
| 374 | }
|
---|
| 375 | }
|
---|