[f9c482b] | 1 | <?php
|
---|
| 2 | /*
|
---|
| 3 | * Copyright 2024 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\ExecutableHandler\ExecutableHandler;
|
---|
| 21 | use Google\Auth\ExecutableHandler\ExecutableResponseError;
|
---|
| 22 | use Google\Auth\ExternalAccountCredentialSourceInterface;
|
---|
| 23 | use RuntimeException;
|
---|
| 24 |
|
---|
| 25 | /**
|
---|
| 26 | * ExecutableSource enables the exchange of workload identity pool external credentials for
|
---|
| 27 | * Google access tokens by retrieving 3rd party tokens through a user supplied executable. These
|
---|
| 28 | * scripts/executables are completely independent of the Google Cloud Auth libraries. These
|
---|
| 29 | * credentials plug into ADC and will call the specified executable to retrieve the 3rd party token
|
---|
| 30 | * to be exchanged for a Google access token.
|
---|
| 31 | *
|
---|
| 32 | * To use these credentials, the GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES environment variable
|
---|
| 33 | * must be set to '1'. This is for security reasons.
|
---|
| 34 | *
|
---|
| 35 | * Both OIDC and SAML are supported. The executable must adhere to a specific response format
|
---|
| 36 | * defined below.
|
---|
| 37 | *
|
---|
| 38 | * The executable must print out the 3rd party token to STDOUT in JSON format. When an
|
---|
| 39 | * output_file is specified in the credential configuration, the executable must also handle writing the
|
---|
| 40 | * JSON response to this file.
|
---|
| 41 | *
|
---|
| 42 | * <pre>
|
---|
| 43 | * OIDC response sample:
|
---|
| 44 | * {
|
---|
| 45 | * "version": 1,
|
---|
| 46 | * "success": true,
|
---|
| 47 | * "token_type": "urn:ietf:params:oauth:token-type:id_token",
|
---|
| 48 | * "id_token": "HEADER.PAYLOAD.SIGNATURE",
|
---|
| 49 | * "expiration_time": 1620433341
|
---|
| 50 | * }
|
---|
| 51 | *
|
---|
| 52 | * SAML2 response sample:
|
---|
| 53 | * {
|
---|
| 54 | * "version": 1,
|
---|
| 55 | * "success": true,
|
---|
| 56 | * "token_type": "urn:ietf:params:oauth:token-type:saml2",
|
---|
| 57 | * "saml_response": "...",
|
---|
| 58 | * "expiration_time": 1620433341
|
---|
| 59 | * }
|
---|
| 60 | *
|
---|
| 61 | * Error response sample:
|
---|
| 62 | * {
|
---|
| 63 | * "version": 1,
|
---|
| 64 | * "success": false,
|
---|
| 65 | * "code": "401",
|
---|
| 66 | * "message": "Error message."
|
---|
| 67 | * }
|
---|
| 68 | * </pre>
|
---|
| 69 | *
|
---|
| 70 | * The "expiration_time" field in the JSON response is only required for successful
|
---|
| 71 | * responses when an output file was specified in the credential configuration
|
---|
| 72 | *
|
---|
| 73 | * The auth libraries will populate certain environment variables that will be accessible by the
|
---|
| 74 | * executable, such as: GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE, GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE,
|
---|
| 75 | * GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE, GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL, and
|
---|
| 76 | * GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE.
|
---|
| 77 | */
|
---|
| 78 | class ExecutableSource implements ExternalAccountCredentialSourceInterface
|
---|
| 79 | {
|
---|
| 80 | private const GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES = 'GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES';
|
---|
| 81 | private const SAML_SUBJECT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:saml2';
|
---|
| 82 | private const OIDC_SUBJECT_TOKEN_TYPE1 = 'urn:ietf:params:oauth:token-type:id_token';
|
---|
| 83 | private const OIDC_SUBJECT_TOKEN_TYPE2 = 'urn:ietf:params:oauth:token-type:jwt';
|
---|
| 84 |
|
---|
| 85 | private string $command;
|
---|
| 86 | private ExecutableHandler $executableHandler;
|
---|
| 87 | private ?string $outputFile;
|
---|
| 88 |
|
---|
| 89 | /**
|
---|
| 90 | * @param string $command The string command to run to get the subject token.
|
---|
| 91 | * @param string|null $outputFile
|
---|
| 92 | */
|
---|
| 93 | public function __construct(
|
---|
| 94 | string $command,
|
---|
| 95 | ?string $outputFile,
|
---|
| 96 | ?ExecutableHandler $executableHandler = null,
|
---|
| 97 | ) {
|
---|
| 98 | $this->command = $command;
|
---|
| 99 | $this->outputFile = $outputFile;
|
---|
| 100 | $this->executableHandler = $executableHandler ?: new ExecutableHandler();
|
---|
| 101 | }
|
---|
| 102 |
|
---|
| 103 | /**
|
---|
| 104 | * Gets the unique key for caching
|
---|
| 105 | * The format for the cache key is:
|
---|
| 106 | * Command.OutputFile
|
---|
| 107 | *
|
---|
| 108 | * @return ?string
|
---|
| 109 | */
|
---|
| 110 | public function getCacheKey(): ?string
|
---|
| 111 | {
|
---|
| 112 | return $this->command . '.' . $this->outputFile;
|
---|
| 113 | }
|
---|
| 114 |
|
---|
| 115 | /**
|
---|
| 116 | * @param callable|null $httpHandler unused.
|
---|
| 117 | * @return string
|
---|
| 118 | * @throws RuntimeException if the executable is not allowed to run.
|
---|
| 119 | * @throws ExecutableResponseError if the executable response is invalid.
|
---|
| 120 | */
|
---|
| 121 | public function fetchSubjectToken(?callable $httpHandler = null): string
|
---|
| 122 | {
|
---|
| 123 | // Check if the executable is allowed to run.
|
---|
| 124 | if (getenv(self::GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES) !== '1') {
|
---|
| 125 | throw new RuntimeException(
|
---|
| 126 | 'Pluggable Auth executables need to be explicitly allowed to run by '
|
---|
| 127 | . 'setting the GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES environment '
|
---|
| 128 | . 'Variable to 1.'
|
---|
| 129 | );
|
---|
| 130 | }
|
---|
| 131 |
|
---|
| 132 | if (!$executableResponse = $this->getCachedExecutableResponse()) {
|
---|
| 133 | // Run the executable.
|
---|
| 134 | $exitCode = ($this->executableHandler)($this->command);
|
---|
| 135 | $output = $this->executableHandler->getOutput();
|
---|
| 136 |
|
---|
| 137 | // If the exit code is not 0, throw an exception with the output as the error details
|
---|
| 138 | if ($exitCode !== 0) {
|
---|
| 139 | throw new ExecutableResponseError(
|
---|
| 140 | 'The executable failed to run'
|
---|
| 141 | . ($output ? ' with the following error: ' . $output : '.'),
|
---|
| 142 | (string) $exitCode
|
---|
| 143 | );
|
---|
| 144 | }
|
---|
| 145 |
|
---|
| 146 | $executableResponse = $this->parseExecutableResponse($output);
|
---|
| 147 |
|
---|
| 148 | // Validate expiration.
|
---|
| 149 | if (isset($executableResponse['expiration_time']) && time() >= $executableResponse['expiration_time']) {
|
---|
| 150 | throw new ExecutableResponseError('Executable response is expired.');
|
---|
| 151 | }
|
---|
| 152 | }
|
---|
| 153 |
|
---|
| 154 | // Throw error when the request was unsuccessful
|
---|
| 155 | if ($executableResponse['success'] === false) {
|
---|
| 156 | throw new ExecutableResponseError($executableResponse['message'], (string) $executableResponse['code']);
|
---|
| 157 | }
|
---|
| 158 |
|
---|
| 159 | // Return subject token field based on the token type
|
---|
| 160 | return $executableResponse['token_type'] === self::SAML_SUBJECT_TOKEN_TYPE
|
---|
| 161 | ? $executableResponse['saml_response']
|
---|
| 162 | : $executableResponse['id_token'];
|
---|
| 163 | }
|
---|
| 164 |
|
---|
| 165 | /**
|
---|
| 166 | * @return array<string, mixed>|null
|
---|
| 167 | */
|
---|
| 168 | private function getCachedExecutableResponse(): ?array
|
---|
| 169 | {
|
---|
| 170 | if (
|
---|
| 171 | $this->outputFile
|
---|
| 172 | && file_exists($this->outputFile)
|
---|
| 173 | && !empty(trim($outputFileContents = (string) file_get_contents($this->outputFile)))
|
---|
| 174 | ) {
|
---|
| 175 | try {
|
---|
| 176 | $executableResponse = $this->parseExecutableResponse($outputFileContents);
|
---|
| 177 | } catch (ExecutableResponseError $e) {
|
---|
| 178 | throw new ExecutableResponseError(
|
---|
| 179 | 'Error in output file: ' . $e->getMessage(),
|
---|
| 180 | 'INVALID_OUTPUT_FILE'
|
---|
| 181 | );
|
---|
| 182 | }
|
---|
| 183 |
|
---|
| 184 | if ($executableResponse['success'] === false) {
|
---|
| 185 | // If the cached token was unsuccessful, run the executable to get a new one.
|
---|
| 186 | return null;
|
---|
| 187 | }
|
---|
| 188 |
|
---|
| 189 | if (isset($executableResponse['expiration_time']) && time() >= $executableResponse['expiration_time']) {
|
---|
| 190 | // If the cached token is expired, run the executable to get a new one.
|
---|
| 191 | return null;
|
---|
| 192 | }
|
---|
| 193 |
|
---|
| 194 | return $executableResponse;
|
---|
| 195 | }
|
---|
| 196 |
|
---|
| 197 | return null;
|
---|
| 198 | }
|
---|
| 199 |
|
---|
| 200 | /**
|
---|
| 201 | * @return array<string, mixed>
|
---|
| 202 | */
|
---|
| 203 | private function parseExecutableResponse(string $response): array
|
---|
| 204 | {
|
---|
| 205 | $executableResponse = json_decode($response, true);
|
---|
| 206 | if (json_last_error() !== JSON_ERROR_NONE) {
|
---|
| 207 | throw new ExecutableResponseError(
|
---|
| 208 | 'The executable returned an invalid response: ' . $response,
|
---|
| 209 | 'INVALID_RESPONSE'
|
---|
| 210 | );
|
---|
| 211 | }
|
---|
| 212 | if (!array_key_exists('version', $executableResponse)) {
|
---|
| 213 | throw new ExecutableResponseError('Executable response must contain a "version" field.');
|
---|
| 214 | }
|
---|
| 215 | if (!array_key_exists('success', $executableResponse)) {
|
---|
| 216 | throw new ExecutableResponseError('Executable response must contain a "success" field.');
|
---|
| 217 | }
|
---|
| 218 |
|
---|
| 219 | // Validate required fields for a successful response.
|
---|
| 220 | if ($executableResponse['success']) {
|
---|
| 221 | // Validate token type field.
|
---|
| 222 | $tokenTypes = [self::SAML_SUBJECT_TOKEN_TYPE, self::OIDC_SUBJECT_TOKEN_TYPE1, self::OIDC_SUBJECT_TOKEN_TYPE2];
|
---|
| 223 | if (!isset($executableResponse['token_type'])) {
|
---|
| 224 | throw new ExecutableResponseError(
|
---|
| 225 | 'Executable response must contain a "token_type" field when successful'
|
---|
| 226 | );
|
---|
| 227 | }
|
---|
| 228 | if (!in_array($executableResponse['token_type'], $tokenTypes)) {
|
---|
| 229 | throw new ExecutableResponseError(sprintf(
|
---|
| 230 | 'Executable response "token_type" field must be one of %s.',
|
---|
| 231 | implode(', ', $tokenTypes)
|
---|
| 232 | ));
|
---|
| 233 | }
|
---|
| 234 |
|
---|
| 235 | // Validate subject token for SAML and OIDC.
|
---|
| 236 | if ($executableResponse['token_type'] === self::SAML_SUBJECT_TOKEN_TYPE) {
|
---|
| 237 | if (empty($executableResponse['saml_response'])) {
|
---|
| 238 | throw new ExecutableResponseError(sprintf(
|
---|
| 239 | 'Executable response must contain a "saml_response" field when token_type=%s.',
|
---|
| 240 | self::SAML_SUBJECT_TOKEN_TYPE
|
---|
| 241 | ));
|
---|
| 242 | }
|
---|
| 243 | } elseif (empty($executableResponse['id_token'])) {
|
---|
| 244 | throw new ExecutableResponseError(sprintf(
|
---|
| 245 | 'Executable response must contain a "id_token" field when '
|
---|
| 246 | . 'token_type=%s.',
|
---|
| 247 | $executableResponse['token_type']
|
---|
| 248 | ));
|
---|
| 249 | }
|
---|
| 250 |
|
---|
| 251 | // Validate expiration exists when an output file is specified.
|
---|
| 252 | if ($this->outputFile) {
|
---|
| 253 | if (!isset($executableResponse['expiration_time'])) {
|
---|
| 254 | throw new ExecutableResponseError(
|
---|
| 255 | 'The executable response must contain a "expiration_time" field for successful responses ' .
|
---|
| 256 | 'when an output_file has been specified in the configuration.'
|
---|
| 257 | );
|
---|
| 258 | }
|
---|
| 259 | }
|
---|
| 260 | } else {
|
---|
| 261 | // Both code and message must be provided for unsuccessful responses.
|
---|
| 262 | if (!array_key_exists('code', $executableResponse)) {
|
---|
| 263 | throw new ExecutableResponseError('Executable response must contain a "code" field when unsuccessful.');
|
---|
| 264 | }
|
---|
| 265 | if (empty($executableResponse['message'])) {
|
---|
| 266 | throw new ExecutableResponseError('Executable response must contain a "message" field when unsuccessful.');
|
---|
| 267 | }
|
---|
| 268 | }
|
---|
| 269 |
|
---|
| 270 | return $executableResponse;
|
---|
| 271 | }
|
---|
| 272 | }
|
---|