Skip to content

Commit

Permalink
feat: add ExecutableSource credentials (#525)
Browse files Browse the repository at this point in the history
  • Loading branch information
bshaffer committed Apr 24, 2024
1 parent 3bcd1fc commit d98900d
Show file tree
Hide file tree
Showing 10 changed files with 873 additions and 6 deletions.
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"sebastian/comparator": ">=1.2.3",
"phpseclib/phpseclib": "^3.0.35",
"kelvinmo/simplejwt": "0.7.1",
"webmozart/assert": "^1.11"
"webmozart/assert": "^1.11",
"symfony/process": "^6.0||^7.0"
},
"suggest": {
"phpseclib/phpseclib": "May be used in place of OpenSSL for signing strings or for token management. Please require version ^2."
Expand Down
260 changes: 260 additions & 0 deletions src/CredentialSource/ExecutableSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
<?php
/*
* Copyright 2024 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

namespace Google\Auth\CredentialSource;

use Google\Auth\ExecutableHandler\ExecutableHandler;
use Google\Auth\ExecutableHandler\ExecutableResponseError;
use Google\Auth\ExternalAccountCredentialSourceInterface;
use RuntimeException;

/**
* ExecutableSource enables the exchange of workload identity pool external credentials for
* Google access tokens by retrieving 3rd party tokens through a user supplied executable. These
* scripts/executables are completely independent of the Google Cloud Auth libraries. These
* credentials plug into ADC and will call the specified executable to retrieve the 3rd party token
* to be exchanged for a Google access token.
*
* To use these credentials, the GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES environment variable
* must be set to '1'. This is for security reasons.
*
* Both OIDC and SAML are supported. The executable must adhere to a specific response format
* defined below.
*
* The executable must print out the 3rd party token to STDOUT in JSON format. When an
* output_file is specified in the credential configuration, the executable must also handle writing the
* JSON response to this file.
*
* <pre>
* OIDC response sample:
* {
* "version": 1,
* "success": true,
* "token_type": "urn:ietf:params:oauth:token-type:id_token",
* "id_token": "HEADER.PAYLOAD.SIGNATURE",
* "expiration_time": 1620433341
* }
*
* SAML2 response sample:
* {
* "version": 1,
* "success": true,
* "token_type": "urn:ietf:params:oauth:token-type:saml2",
* "saml_response": "...",
* "expiration_time": 1620433341
* }
*
* Error response sample:
* {
* "version": 1,
* "success": false,
* "code": "401",
* "message": "Error message."
* }
* </pre>
*
* The "expiration_time" field in the JSON response is only required for successful
* responses when an output file was specified in the credential configuration
*
* The auth libraries will populate certain environment variables that will be accessible by the
* executable, such as: GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE, GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE,
* GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE, GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL, and
* GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE.
*/
class ExecutableSource implements ExternalAccountCredentialSourceInterface
{
private const GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES = 'GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES';
private const SAML_SUBJECT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:saml2';
private const OIDC_SUBJECT_TOKEN_TYPE1 = 'urn:ietf:params:oauth:token-type:id_token';
private const OIDC_SUBJECT_TOKEN_TYPE2 = 'urn:ietf:params:oauth:token-type:jwt';

private string $command;
private ExecutableHandler $executableHandler;
private ?string $outputFile;

/**
* @param string $command The string command to run to get the subject token.
* @param string $outputFile
*/
public function __construct(
string $command,
?string $outputFile,
ExecutableHandler $executableHandler = null,
) {
$this->command = $command;
$this->outputFile = $outputFile;
$this->executableHandler = $executableHandler ?: new ExecutableHandler();
}

/**
* @param callable $httpHandler unused.
* @return string
* @throws RuntimeException if the executable is not allowed to run.
* @throws ExecutableResponseError if the executable response is invalid.
*/
public function fetchSubjectToken(callable $httpHandler = null): string
{
// Check if the executable is allowed to run.
if (getenv(self::GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES) !== '1') {
throw new RuntimeException(
'Pluggable Auth executables need to be explicitly allowed to run by '
. 'setting the GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES environment '
. 'Variable to 1.'
);
}

if (!$executableResponse = $this->getCachedExecutableResponse()) {
// Run the executable.
$exitCode = ($this->executableHandler)($this->command);
$output = $this->executableHandler->getOutput();

// If the exit code is not 0, throw an exception with the output as the error details
if ($exitCode !== 0) {
throw new ExecutableResponseError(
'The executable failed to run'
. ($output ? ' with the following error: ' . $output : '.'),
(string) $exitCode
);
}

$executableResponse = $this->parseExecutableResponse($output);

// Validate expiration.
if (isset($executableResponse['expiration_time']) && time() >= $executableResponse['expiration_time']) {
throw new ExecutableResponseError('Executable response is expired.');
}
}

// Throw error when the request was unsuccessful
if ($executableResponse['success'] === false) {
throw new ExecutableResponseError($executableResponse['message'], (string) $executableResponse['code']);
}

// Return subject token field based on the token type
return $executableResponse['token_type'] === self::SAML_SUBJECT_TOKEN_TYPE
? $executableResponse['saml_response']
: $executableResponse['id_token'];
}

/**
* @return array<string, mixed>|null
*/
private function getCachedExecutableResponse(): ?array
{
if (
$this->outputFile
&& file_exists($this->outputFile)
&& !empty(trim($outputFileContents = (string) file_get_contents($this->outputFile)))
) {
try {
$executableResponse = $this->parseExecutableResponse($outputFileContents);
} catch (ExecutableResponseError $e) {
throw new ExecutableResponseError(
'Error in output file: ' . $e->getMessage(),
'INVALID_OUTPUT_FILE'
);
}

if ($executableResponse['success'] === false) {
// If the cached token was unsuccessful, run the executable to get a new one.
return null;
}

if (isset($executableResponse['expiration_time']) && time() >= $executableResponse['expiration_time']) {
// If the cached token is expired, run the executable to get a new one.
return null;
}

return $executableResponse;
}

return null;
}

/**
* @return array<string, mixed>
*/
private function parseExecutableResponse(string $response): array
{
$executableResponse = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new ExecutableResponseError(
'The executable returned an invalid response: ' . $response,
'INVALID_RESPONSE'
);
}
if (!array_key_exists('version', $executableResponse)) {
throw new ExecutableResponseError('Executable response must contain a "version" field.');
}
if (!array_key_exists('success', $executableResponse)) {
throw new ExecutableResponseError('Executable response must contain a "success" field.');
}

// Validate required fields for a successful response.
if ($executableResponse['success']) {
// Validate token type field.
$tokenTypes = [self::SAML_SUBJECT_TOKEN_TYPE, self::OIDC_SUBJECT_TOKEN_TYPE1, self::OIDC_SUBJECT_TOKEN_TYPE2];
if (!isset($executableResponse['token_type'])) {
throw new ExecutableResponseError(
'Executable response must contain a "token_type" field when successful'
);
}
if (!in_array($executableResponse['token_type'], $tokenTypes)) {
throw new ExecutableResponseError(sprintf(
'Executable response "token_type" field must be one of %s.',
implode(', ', $tokenTypes)
));
}

// Validate subject token for SAML and OIDC.
if ($executableResponse['token_type'] === self::SAML_SUBJECT_TOKEN_TYPE) {
if (empty($executableResponse['saml_response'])) {
throw new ExecutableResponseError(sprintf(
'Executable response must contain a "saml_response" field when token_type=%s.',
self::SAML_SUBJECT_TOKEN_TYPE
));
}
} elseif (empty($executableResponse['id_token'])) {
throw new ExecutableResponseError(sprintf(
'Executable response must contain a "id_token" field when '
. 'token_type=%s.',
$executableResponse['token_type']
));
}

// Validate expiration exists when an output file is specified.
if ($this->outputFile) {
if (!isset($executableResponse['expiration_time'])) {
throw new ExecutableResponseError(
'The executable response must contain a "expiration_time" field for successful responses ' .
'when an output_file has been specified in the configuration.'
);
}
}
} else {
// Both code and message must be provided for unsuccessful responses.
if (!array_key_exists('code', $executableResponse)) {
throw new ExecutableResponseError('Executable response must contain a "code" field when unsuccessful.');
}
if (empty($executableResponse['message'])) {
throw new ExecutableResponseError('Executable response must contain a "message" field when unsuccessful.');
}
}

return $executableResponse;
}
}
44 changes: 39 additions & 5 deletions src/Credentials/ExternalAccountCredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@
namespace Google\Auth\Credentials;

use Google\Auth\CredentialSource\AwsNativeSource;
use Google\Auth\CredentialSource\ExecutableSource;
use Google\Auth\CredentialSource\FileSource;
use Google\Auth\CredentialSource\UrlSource;
use Google\Auth\ExecutableHandler\ExecutableHandler;
use Google\Auth\ExternalAccountCredentialSourceInterface;
use Google\Auth\FetchAuthTokenInterface;
use Google\Auth\GetQuotaProjectInterface;
Expand Down Expand Up @@ -150,11 +152,6 @@ private static function buildCredentialSource(array $jsonKey): ExternalAccountCr
'The regional_cred_verification_url field is required for aws1 credential source.'
);
}
if (!array_key_exists('audience', $jsonKey)) {
throw new InvalidArgumentException(
'aws1 credential source requires an audience to be set in the JSON file.'
);
}

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

if (isset($credentialSource['executable'])) {
if (!array_key_exists('command', $credentialSource['executable'])) {
throw new InvalidArgumentException(
'executable source requires a command to be set in the JSON file.'
);
}

// Build command environment variables
$env = [
'GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE' => $jsonKey['audience'],
'GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE' => $jsonKey['subject_token_type'],
// Always set to 0 because interactive mode is not supported.
'GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE' => '0',
];

if ($outputFile = $credentialSource['executable']['output_file'] ?? null) {
$env['GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE'] = $outputFile;
}

if ($serviceAccountImpersonationUrl = $jsonKey['service_account_impersonation_url'] ?? null) {
// Parse email from URL. The formal looks as follows:
// https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/name@project-id.iam.gserviceaccount.com:generateAccessToken
$regex = '/serviceAccounts\/(?<email>[^:]+):generateAccessToken$/';
if (preg_match($regex, $serviceAccountImpersonationUrl, $matches)) {
$env['GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL'] = $matches['email'];
}
}

$timeoutMs = $credentialSource['executable']['timeout_millis'] ?? null;

return new ExecutableSource(
$credentialSource['executable']['command'],
$outputFile,
$timeoutMs ? new ExecutableHandler($env, $timeoutMs) : new ExecutableHandler($env)
);
}

throw new InvalidArgumentException('Unable to determine credential source from json key.');
}

Expand Down

0 comments on commit d98900d

Please sign in to comment.