Skip to content

Commit d98900d

Browse files
authored
feat: add ExecutableSource credentials (#525)
1 parent 3bcd1fc commit d98900d

10 files changed

Lines changed: 873 additions & 6 deletions

File tree

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
"sebastian/comparator": ">=1.2.3",
2525
"phpseclib/phpseclib": "^3.0.35",
2626
"kelvinmo/simplejwt": "0.7.1",
27-
"webmozart/assert": "^1.11"
27+
"webmozart/assert": "^1.11",
28+
"symfony/process": "^6.0||^7.0"
2829
},
2930
"suggest": {
3031
"phpseclib/phpseclib": "May be used in place of OpenSSL for signing strings or for token management. Please require version ^2."
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
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 $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+
* @param callable $httpHandler unused.
105+
* @return string
106+
* @throws RuntimeException if the executable is not allowed to run.
107+
* @throws ExecutableResponseError if the executable response is invalid.
108+
*/
109+
public function fetchSubjectToken(callable $httpHandler = null): string
110+
{
111+
// Check if the executable is allowed to run.
112+
if (getenv(self::GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES) !== '1') {
113+
throw new RuntimeException(
114+
'Pluggable Auth executables need to be explicitly allowed to run by '
115+
. 'setting the GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES environment '
116+
. 'Variable to 1.'
117+
);
118+
}
119+
120+
if (!$executableResponse = $this->getCachedExecutableResponse()) {
121+
// Run the executable.
122+
$exitCode = ($this->executableHandler)($this->command);
123+
$output = $this->executableHandler->getOutput();
124+
125+
// If the exit code is not 0, throw an exception with the output as the error details
126+
if ($exitCode !== 0) {
127+
throw new ExecutableResponseError(
128+
'The executable failed to run'
129+
. ($output ? ' with the following error: ' . $output : '.'),
130+
(string) $exitCode
131+
);
132+
}
133+
134+
$executableResponse = $this->parseExecutableResponse($output);
135+
136+
// Validate expiration.
137+
if (isset($executableResponse['expiration_time']) && time() >= $executableResponse['expiration_time']) {
138+
throw new ExecutableResponseError('Executable response is expired.');
139+
}
140+
}
141+
142+
// Throw error when the request was unsuccessful
143+
if ($executableResponse['success'] === false) {
144+
throw new ExecutableResponseError($executableResponse['message'], (string) $executableResponse['code']);
145+
}
146+
147+
// Return subject token field based on the token type
148+
return $executableResponse['token_type'] === self::SAML_SUBJECT_TOKEN_TYPE
149+
? $executableResponse['saml_response']
150+
: $executableResponse['id_token'];
151+
}
152+
153+
/**
154+
* @return array<string, mixed>|null
155+
*/
156+
private function getCachedExecutableResponse(): ?array
157+
{
158+
if (
159+
$this->outputFile
160+
&& file_exists($this->outputFile)
161+
&& !empty(trim($outputFileContents = (string) file_get_contents($this->outputFile)))
162+
) {
163+
try {
164+
$executableResponse = $this->parseExecutableResponse($outputFileContents);
165+
} catch (ExecutableResponseError $e) {
166+
throw new ExecutableResponseError(
167+
'Error in output file: ' . $e->getMessage(),
168+
'INVALID_OUTPUT_FILE'
169+
);
170+
}
171+
172+
if ($executableResponse['success'] === false) {
173+
// If the cached token was unsuccessful, run the executable to get a new one.
174+
return null;
175+
}
176+
177+
if (isset($executableResponse['expiration_time']) && time() >= $executableResponse['expiration_time']) {
178+
// If the cached token is expired, run the executable to get a new one.
179+
return null;
180+
}
181+
182+
return $executableResponse;
183+
}
184+
185+
return null;
186+
}
187+
188+
/**
189+
* @return array<string, mixed>
190+
*/
191+
private function parseExecutableResponse(string $response): array
192+
{
193+
$executableResponse = json_decode($response, true);
194+
if (json_last_error() !== JSON_ERROR_NONE) {
195+
throw new ExecutableResponseError(
196+
'The executable returned an invalid response: ' . $response,
197+
'INVALID_RESPONSE'
198+
);
199+
}
200+
if (!array_key_exists('version', $executableResponse)) {
201+
throw new ExecutableResponseError('Executable response must contain a "version" field.');
202+
}
203+
if (!array_key_exists('success', $executableResponse)) {
204+
throw new ExecutableResponseError('Executable response must contain a "success" field.');
205+
}
206+
207+
// Validate required fields for a successful response.
208+
if ($executableResponse['success']) {
209+
// Validate token type field.
210+
$tokenTypes = [self::SAML_SUBJECT_TOKEN_TYPE, self::OIDC_SUBJECT_TOKEN_TYPE1, self::OIDC_SUBJECT_TOKEN_TYPE2];
211+
if (!isset($executableResponse['token_type'])) {
212+
throw new ExecutableResponseError(
213+
'Executable response must contain a "token_type" field when successful'
214+
);
215+
}
216+
if (!in_array($executableResponse['token_type'], $tokenTypes)) {
217+
throw new ExecutableResponseError(sprintf(
218+
'Executable response "token_type" field must be one of %s.',
219+
implode(', ', $tokenTypes)
220+
));
221+
}
222+
223+
// Validate subject token for SAML and OIDC.
224+
if ($executableResponse['token_type'] === self::SAML_SUBJECT_TOKEN_TYPE) {
225+
if (empty($executableResponse['saml_response'])) {
226+
throw new ExecutableResponseError(sprintf(
227+
'Executable response must contain a "saml_response" field when token_type=%s.',
228+
self::SAML_SUBJECT_TOKEN_TYPE
229+
));
230+
}
231+
} elseif (empty($executableResponse['id_token'])) {
232+
throw new ExecutableResponseError(sprintf(
233+
'Executable response must contain a "id_token" field when '
234+
. 'token_type=%s.',
235+
$executableResponse['token_type']
236+
));
237+
}
238+
239+
// Validate expiration exists when an output file is specified.
240+
if ($this->outputFile) {
241+
if (!isset($executableResponse['expiration_time'])) {
242+
throw new ExecutableResponseError(
243+
'The executable response must contain a "expiration_time" field for successful responses ' .
244+
'when an output_file has been specified in the configuration.'
245+
);
246+
}
247+
}
248+
} else {
249+
// Both code and message must be provided for unsuccessful responses.
250+
if (!array_key_exists('code', $executableResponse)) {
251+
throw new ExecutableResponseError('Executable response must contain a "code" field when unsuccessful.');
252+
}
253+
if (empty($executableResponse['message'])) {
254+
throw new ExecutableResponseError('Executable response must contain a "message" field when unsuccessful.');
255+
}
256+
}
257+
258+
return $executableResponse;
259+
}
260+
}

src/Credentials/ExternalAccountCredentials.php

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@
1818
namespace Google\Auth\Credentials;
1919

2020
use Google\Auth\CredentialSource\AwsNativeSource;
21+
use Google\Auth\CredentialSource\ExecutableSource;
2122
use Google\Auth\CredentialSource\FileSource;
2223
use Google\Auth\CredentialSource\UrlSource;
24+
use Google\Auth\ExecutableHandler\ExecutableHandler;
2325
use Google\Auth\ExternalAccountCredentialSourceInterface;
2426
use Google\Auth\FetchAuthTokenInterface;
2527
use Google\Auth\GetQuotaProjectInterface;
@@ -150,11 +152,6 @@ private static function buildCredentialSource(array $jsonKey): ExternalAccountCr
150152
'The regional_cred_verification_url field is required for aws1 credential source.'
151153
);
152154
}
153-
if (!array_key_exists('audience', $jsonKey)) {
154-
throw new InvalidArgumentException(
155-
'aws1 credential source requires an audience to be set in the JSON file.'
156-
);
157-
}
158155

159156
return new AwsNativeSource(
160157
$jsonKey['audience'],
@@ -174,6 +171,43 @@ private static function buildCredentialSource(array $jsonKey): ExternalAccountCr
174171
);
175172
}
176173

174+
if (isset($credentialSource['executable'])) {
175+
if (!array_key_exists('command', $credentialSource['executable'])) {
176+
throw new InvalidArgumentException(
177+
'executable source requires a command to be set in the JSON file.'
178+
);
179+
}
180+
181+
// Build command environment variables
182+
$env = [
183+
'GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE' => $jsonKey['audience'],
184+
'GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE' => $jsonKey['subject_token_type'],
185+
// Always set to 0 because interactive mode is not supported.
186+
'GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE' => '0',
187+
];
188+
189+
if ($outputFile = $credentialSource['executable']['output_file'] ?? null) {
190+
$env['GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE'] = $outputFile;
191+
}
192+
193+
if ($serviceAccountImpersonationUrl = $jsonKey['service_account_impersonation_url'] ?? null) {
194+
// Parse email from URL. The formal looks as follows:
195+
// https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/name@project-id.iam.gserviceaccount.com:generateAccessToken
196+
$regex = '/serviceAccounts\/(?<email>[^:]+):generateAccessToken$/';
197+
if (preg_match($regex, $serviceAccountImpersonationUrl, $matches)) {
198+
$env['GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL'] = $matches['email'];
199+
}
200+
}
201+
202+
$timeoutMs = $credentialSource['executable']['timeout_millis'] ?? null;
203+
204+
return new ExecutableSource(
205+
$credentialSource['executable']['command'],
206+
$outputFile,
207+
$timeoutMs ? new ExecutableHandler($env, $timeoutMs) : new ExecutableHandler($env)
208+
);
209+
}
210+
177211
throw new InvalidArgumentException('Unable to determine credential source from json key.');
178212
}
179213

0 commit comments

Comments
 (0)