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 | }
|
---|